Skip to content

fix(editor): keep numbered list start number in markdown and html export - #15726

Merged
darkskygit merged 2 commits into
toeverything:canaryfrom
b84330808:fix/list-export-start-number
Nov 8, 2026
Merged

darkskygit merged 2 commits into
toeverything:canaryfrom
b84330808:fix/list-export-start-number

Conversation

@b84330808

@b84330808 b84330808 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Description

Closes #14098

When a numbered list doesn't start at 1 (for example it picks up at 3 after a paragraph or a code block), AFFiNE keeps that number in the list block's order prop. Importing markdown/html already reads the list's start number into order, but the markdown and html exporters never wrote it back, so the exported list always restarted at 1.

This sets start on the exported markdown list / <ol> from the first item's order when it's above 1. Lists that start at 1, or older blocks without order, export exactly as before. I added a test for each exporter; the markdown one is the reverse of the existing "non consecutive numbered list" import test. PDF export already used order, so it wasn't affected.

I used Claude Code to write this change.

Checklist

  • I have signed the AFFiNE Contributor License Agreement — required before merge; the license/cla check must be green (how it works)
  • The PR targets the canary branch and its title follows Conventional Commits
  • Tests are added or updated where it makes sense
  • yarn lint and yarn typecheck pass locally
  • If the PR code includes AI-generated edits, I have carefully reviewed it to ensure that the scope of the PR is consistent with what is claimed in the title and description, and that there are no redundant or invalid implementations.
  • I agree that maintainers may close the PR without discussion if they consider the code quality to be too low.

A numbered list that doesn't start at 1 (for example one that continues
after a code block) stores its number in the `order` prop. The markdown
and html importers read the start number into `order`, but the exporters
ignored it, so such a list was always exported starting from 1.

Set `start` on the exported list from the first item's `order` when it is
greater than 1.

Fixes toeverything#14098

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@CLAassistant

CLAassistant commented Oct 21, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions Bot added the test Related to test cases label Oct 21, 2026
@coderabbitai

coderabbitai Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Walkthrough

HTML and Markdown export adapters now preserve numbered-list starting positions greater than 1. Tests cover list items numbered 1, 3, and 4, with a paragraph between list segments.

Changes: Numbered-list export

Layer / File(s) Summary
Preserve numbered-list starts
blocksuite/affine/blocks/list/src/adapters/html.ts, blocksuite/affine/blocks/list/src/adapters/markdown.ts, blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts, blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts
The HTML adapter sets the ordered list’s start attribute when an item’s order is greater than 1. The Markdown adapter sets the list’s start value under the same condition. Tests check that separated list segments retain their numbering.

Estimated code review effort: 2 (Simple) | ~10 minutes

Change: Bug fix · Severity of issue fixed: Low

Suggested reviewers: akumatus

Merge Risk: Low · up to f56cf

Export now preserves the start of a numbered list, but adjacent items with a numbering jump can still export with the wrong number. This is a narrow formatting gap to address or explicitly accept before merging.

Pre-merge checks: Passed 4

Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title clearly and concisely describes the main change: preserving numbered-list start numbers in Markdown and HTML export.
Linked Issues check Passed Issue [#14098] requires HTML and Markdown export to preserve numbered-list start values. The HTML adapter sets ol.start from the first numbered block's order when order > 1. The Markdown adapter…
Out of Scope Changes check Passed The changes are limited to the two list exporters and their focused HTML and Markdown adapter tests. These changes directly support issue [#14098]. No unrelated implementation or test changes are show…
Full details: Linked Issues check

Explanation

Issue [#14098] requires HTML and Markdown export to preserve numbered-list start values. The HTML adapter sets ol.start from the first numbered block's order when order &gt; 1. The Markdown adapter sets the MDAST list start value under the same condition. Existing behavior remains unchanged for order values at or below 1 and for missing order. The added tests cover separated numbered-list segments in both exporters.

Full details: Out of Scope Changes check

Explanation

The changes are limited to the two list exporters and their focused HTML and Markdown adapter tests. These changes directly support issue [#14098]. No unrelated implementation or test changes are shown in the supplied pull request evidence.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 4ce0abc0-2638-43d1-98b6-43b79beca8e8
📥 Commits

Reviewing files that changed from the base of the PR and between 0b69a6d and f56cfc2.

📒 Files selected for processing: 4
  • blocksuite/affine/all/src/__tests__/adapters/html.unit.spec.ts
  • blocksuite/affine/all/src/__tests__/adapters/markdown.unit.spec.ts
  • blocksuite/affine/blocks/list/src/adapters/html.ts
  • blocksuite/affine/blocks/list/src/adapters/markdown.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 9 remain after this review.

@codecov

codecov Bot commented Nov 8, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 55.08%. Comparing base (3ecd57a) to head (ab1e9af).

Additional details and impacted files
@@            Coverage Diff             @@
##           canary   #15726      +/-   ##
==========================================
- Coverage   55.10%   55.08%   -0.02%     
==========================================
  Files        4032     4032              
  Lines      193887   193893       +6     
  Branches    31462    31460       -2     
==========================================
- Hits       106847   106814      -33     
- Misses      83022    83067      +45     
+ Partials     4018     4012       -6     
Flag Coverage Δ
server-test 78.69% <ø> (-0.04%) ⬇️
unittest 34.37% <100.00%> (-0.01%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@darkskygit
darkskygit merged commit 2e0f115 into toeverything:canary Nov 8, 2026
formicoidea added a commit to formicoidea/blocksuite-labre that referenced this pull request Nov 13, 2026
… for 3.5.0 (#664)

* feat(edgeless): build a mind map with no editor, text measurement injected

buildMindmap on @labre/affine-gfx-mindmap/interchange (core:
gfx/mindmap/interchange) returns the elements the toolbar's mind map
writes: one styled shape per node, sized as fitContent sizes it from the
caller's MindmapTextMeasure, placed by the view's layout, then the
mindmap element naming them by local id. materializeInterchangeImport
rewrites a mind map's children and their parent onto the minted ids.

layout.ts is typed against a structural LayoutNode; style.ts loads no
lodash (applyNodeStyle moves to apply-style.ts) and joins model
./headless; the headless guard allows @toeverything/theme (data only).

ADR 0045, amendment of 2026-11-10. Refs #662.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(edgeless): bound a built mind map's text and wrap long words in linear time

A server builds what a model sent: a node holds at most 2,000
characters, a word wider than the line is cut searching forward (a line
costs its own width), the widest line is a reduce rather than a spread,
and the materializer passes a mind map entry that is not a record
through instead of throwing after the nodes are written.

Refs #662.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore(edgeless): regenerate the architecture map for the mind map builder

Refs #662.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(edgeless): read node boxes through the surface in the mind map parity spec

Refs #662.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(edgeless): draw the canvas viewer under a strict content security policy

Labre's site serves the viewer with `style-src 'self'` and no
`'wasm-unsafe-eval'`. The 3.5.0-rc.0 viewer mounted there and drew
nothing. Reproduced in a browser against that policy (203 reports),
then fixed:

- every light-DOM component appended its styles as a `<style>` in
  `<head>`: `adoptShadowlessStyles()` (std) moves the registered ones,
  and every later one, to `document.adoptedStyleSheets`, which a CSP
  does not block; the viewer calls it, the editor keeps `<style>`;
- the viewer's own rule was a `<style>`: it is an adopted sheet now, and
  it carries the two declarations `@blocksuite/icons` binds as a `style`
  attribute;
- the code block highlighter compiled Oniguruma to WebAssembly at mount:
  `CodeBlockConfig.engine` picks shiki's JavaScript engine, which the
  viewer asks for (the editor keeps Oniguruma);
- emotion's one `<style>` (tables, databases) has a null sheet under the
  policy: its registered and later rules go to an adopted sheet too;
- the paragraph block rendered a `<style>` in its template: the rule
  moved to its static styles, a higher specificity than the heading
  icon's own rule, so the editor's cascade is unchanged;
- the file drop is not mounted by the viewer (its indicator in `<body>`).

Two reports remain and change nothing on screen: emotion's `<style>`,
created at import, and the icons' `style` attribute.

Found on the way, also fixed: a click on a note's content never reached
its link in a browser, because the note's mask covers it; the link is
now resolved from the elements under the pointer. And `docTitles` names
the other pages, which page references showed as deleted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(edgeless): settle synced docs of named pages and follow only the clicked block

Found by a fresh review of the strict-CSP round:

- a page listed in `docTitles` became an empty doc with no root, so a
  synced-doc card of it waited forever for content; it now gets an empty
  page with its title and settles;
- the click read every element under the pointer, so a card hidden under
  another note could be followed; the lookup stops at the first block
  under the pointer, and reads the container's root node, so it also
  works inside a shadow root;
- in a shadow root, a class leaving removed every copy of a shared sheet;
  it now removes one occurrence per sheet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): keep emotion's rules out of a style element a strict policy blocks

Emotion (the table and database blocks) created its `<style>` at import,
before any canvas viewer existed: one report under the published site's
`style-src`, whose criterion is none.

Every `css` now comes from `@labre/affine-shared/styles`, the library's
one emotion instance, whose rules wait for the next microtask before
reaching emotion's `<style>`. The editor gets them there as before,
before its first render. The canvas viewer calls `adoptEmotionStyles()`
(with `adoptShadowlessStyles()`) at import, while the modules are still
evaluating, so the rules go to an adopted sheet and no `<style>` is ever
created. The replay from `cache.registered` in the viewer is gone.

`@emotion/css` moves from the table and database packages to
`@labre/affine-shared`, the one place that imports it now.

Checked in a browser under the viewer pages' policy (`script-src 'self'
'wasm-unsafe-eval'; style-src 'self'; style-src-attr 'unsafe-inline'`):
no report, no `<style>` in `<head>`, the table styled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(edgeless): make the canvas viewer the editor itself, derived from the registry

The PO's principle for the viewer: a mirror of the interactive editor,
generated by the library so that changing the editor changes it, with no
maintenance of its own (ADR 0051, superseding in part ADR 0050).

`mountCanvasViewer` now mounts the `edgeless` scope of the view registry
(every flag on) plus the host's providers (`viewExtensions`: each
framework bundle, which the published core leaves to the bundles), minus
`READ_ONLY_EXCLUDED_VIEW_EXTENSIONS` and `READ_ONLY_EXCLUDED_EXTENSIONS`,
the one list of tooling, kept beside the registry in `extensions/view.ts`
with `getReadOnlyViewProviders`. On the read-only store the editor takes
the pan tool, so the viewer's own pan, wheel and pinch handlers are gone,
and with them the shared wheel module ADR 0050 asked for: the edgeless
root is back to the default branch, unchanged.

The viewer keeps only the host's integration (links resolved under a
note's mask, the frozen view of a frame, framing, the light theme) and
the strict-policy measures.

`read-only-view.unit.spec.ts` compares what the editor and the viewer
register in a real DI container: no excluded entry may paint content (a
block view, a canvas element's view or renderer), nothing may be missing
unless excluded, no exclusion may be stale. Excluding the paragraph
provider makes it fail on `BlockView|affine:paragraph`.

Checked in a browser under the viewer pages' policy: the editor root
mounts, no report, the drag pans, a click on a page reference reaches
`onOpenDoc`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(edgeless): paint the canvas viewer in a browser under a strict policy

The 3.5.0-rc.0 viewer mounted on Labre's site and drew nothing under the
page's Content-Security-Policy. This spec mounts the viewer in a frame
whose policy is stricter than the site's (`script-src 'self'`, no
`'wasm-unsafe-eval'`; `style-src 'self'`; `style-src-attr
'unsafe-inline'`), on a page whose frame holds a filled shape, and counts
the shape's pixels on the canvases: a whole canvas and one frame frozen
in a page. It also fails on any `<style>` element or any policy report
whose source is the library rather than Vite's own dev CSS injection.

With `adoptShadowlessStyles()` switched off, it fails as the site did:
0 pixels on the whole canvas.

A shim module defines `__vitest_browser_runner__.wrapDynamicImport`,
which Vitest's browser mode writes into every dynamic `import()` it
serves and which only its own frame defines.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(edgeless): viewer guard reads the mounted list, keys and clicks after a drag

- getReadOnlyViewExtensions is the one list mountCanvasViewer mounts; the
  guard reads it instead of recomposing it, and counts AffineInlineSpec as
  content
- a key typed while the pointer rests on an interactive canvas reaches the
  page: the editor's dispatcher pauses for that key, then resumes
- a keyboard click (detail 0) never counts as the end of a drag
- the spec names its constants and no longer leaves an unused variable

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore(edgeless): regenerate the architecture map after the merge

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(database): read a malformed cell as its property default

DatabaseBlockDataSource.getCellValue returned undefined when the stored
value failed the property's rawValue.schema (a type change, an import, a
peer on another version). It now returns rawValue.default(), at the read
boundary only: the stored value is kept as is and nothing is written.

Port of toeverything/AFFiNE#15809 (1433ac7). Closes #656.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): keep a fractional index key before its next neighbour

When the subkeys of a and b share a prefix,
generateFractionalIndexingKeyBetween(a, b) appended '0' and a random
postfix to generateKeyBetween(a, b). If b itself continues with that key
plus '0', the postfix decided the order and the new key could sort after
b: inserting a table row or column right after the first one reordered
it within about twenty inserts. The key now moves towards b until b no
longer continues with key + '0' (upstream toeverything/AFFiNE#15709).

Only newly generated keys change; stored keys are read and compared as
before.

Closes #655

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(blocks): open the icon picker outside an editor from a public entry

`@labre/affine/components/icon-picker` re-exports the icon picker so a host
importing only public entries can reuse it (issue 636: the Labre shell's
workspace icon). `openIconPicker(anchor, options)` opens `affine-icon-picker`
in a portal anchored to the element and resolves with the picked `IconData`
(plain JSON), `null` for Remove, or `undefined` on Escape, a click outside,
the host's `signal`, the anchor leaving or a second opening; the popup
closes in every case and Escape stops before a host dialog.

The three elements take an optional `translation` (the host's
`TranslationService`) so the picker reads in the host's language without
`std`, English otherwise. `effects()` is now idempotent; nothing is defined
at import. The playground debug bar gains an "Icon Picker (host demo)"
button that goes through the public entry only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): keep the canvas selection when a table selects or clears

The table selection controller wrote its selection with `set` and
cleared it with `clear()`, both of which replace every selection group.
In edgeless that dropped the `gfx` group: the note being edited left edit
mode when the caret entered a cell, and selecting then clearing a column
wiped an unrelated canvas selection. `setSelected` now writes only the
`TableSelection` group through `setGroup` (issue #659, upstream
toeverything/AFFiNE#15791).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): keep the start number of a numbered list on export

A numbered list that does not start at 1 keeps its start number in the
order prop of its first list block, and the Markdown and HTML imports
already read it from the list start. Both exports dropped it, so the
exported list restarted at 1 and an export then import round trip lost
the numbering. The markdown and html list adapters now write start on a
new numbered list when the first item's order is an integer above 1
(upstream toeverything/AFFiNE#15726). A list at 1 or without an order
exports unchanged. The clipboard's text/html copy uses the same adapter.

Closes #658

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(edgeless): wrap a built mind map's text exactly as the editor does

The builder's own wrap dropped the trailing space wrapText keeps on a line
broken before a word, ignored hyphens and cut long words one character later,
so a node long enough to break was stored 4 to 9 px narrower than the editor
lays it out. wrapMindmapText is now wrapText line for line with the injected
width, held to the real function on a corpus by mindmap-headless.spec.ts.

A measure answering NaN, a negative or an infinite size throws
MindmapMeasureError instead of looping. The parity spec now covers the four
styles and three layouts, a wrapping node, and a toolbar-made map read prop
for prop; the unit spec tests both caps at their bound, a refusal that quotes
no text, the measure as the only source of size, and the child index against
generateKeyBetween up to the node cap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(edgeless): guard the mind map style colour past the palette

Dropping lodash last from the style getters must leave a node deeper than
style two's palette on its last colour. Checked once against the getters
before the change on 12 000 paths of the four styles; this keeps the branch
that changed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(playground): build a mind map with no editor from the debug menu

Build Mind Map (headless) calls buildMindmap with the browser's measure,
writes the result with the materializer at the top-left of the view, and
reports how many nodes the editor's own layout moved on landing (zero is the
expected answer). Each click takes the next of the four styles.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore(playground): regenerate the architecture map for the mind map recette

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(edgeless): show a focused frame's title badge in the canvas viewer

A frozen frame was framed edge to edge, so the badge the editor draws above
the frame's top edge was clipped. The view now keeps a strip of the badge's
height (28px) above a titled frame: its aspect-ratio adds that strip for the
current width and is read again on each resize, and fit() frames the frame
under it.

The viewer's adopted sheet also carries vertical-align: middle for a page
reference's icon, which @blocksuite/icons asks for in a style attribute that
a strict policy refuses. CommunityCanvasTextFonts is re-exported from the
canvas-viewer entry for a public page that serves the faces itself.

Guards: the read-only view spec names the frame-title widget among the
viewer's and the editing toolbars outside it (excluding the widget made the
four other cases pass and this one fail); the viewer spec asserts that mount,
zoom and a click leave the shown Y.Doc's state vector unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(edgeless): paint the canvas viewer under the site's own policy

The spec ran with style-src-attr 'unsafe-inline', so it could not see an
attribute report. It now runs under default-src 'none'; script-src 'self';
style-src 'self'; font-src 'self', waits until the shape is painted instead
of a fixed delay (180s deadline, cold dev-server imports took over 60s here),
and its page holds a linked-doc card, a formatted note and a page-only note.

Both cases assert painted pixels, no style element, the frame badge shown
inside the view and no editing chrome. The two known reports are named and
let through: one style-src-attr per icon whose declarations the viewer's
sheet carries, and the data: drag image pragmatic-drag-and-drop preloads.
Any other report fails.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: tell a host what a strict policy needs from the canvas viewer

The static HTML guide says what importing the viewer already calls
(adoptShadowlessStyles, adoptEmotionStyles, the JavaScript regex engine),
what the policy still needs from the host, which reports remain and why the
viewer belongs on a page without the editor. ADR 0051 gains an amendment
with the measurements under the site's policy and the frame badge.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(playground): open the document in the canvas viewer

The debug menu's "Open in Canvas Viewer" serializes the document and posts
it to examples/canvas-viewer, a page of its own (importing the viewer moves
the page's styles to adopted sheets). A selected frame or group opens
frozen; a click expands it. The strict variant loads under Labre Sites'
policy.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): flip the code toolbar wrap and line-number switches once

In the code block's "more" menu, a click on the Wrap or Line number
switch flipped the switch and bubbled up to its `editor-menu-action`
row, which flipped the setting a second time; the switch was bound
through the `checked` attribute, so its drawn state drifted from the
model (issue #660, upstream toeverything/AFFiNE#15782).

`ToggleSwitch` now stops the click at its label, reads the new value from
the checkbox and binds `.checked` as a property. Each code toolbar row
hands its switch an `onChange` that shares the row's write, so a click on
the switch or on the row flips the setting once, and both refuse a
readonly store before writing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(database): draw a blank select option as untitled

A select or multi-select option whose name is empty or blank drew an
empty pill: invisible in cells and in the tag menu, and as a kanban group
title a zero-height strip that could not be clicked to rename it.

The pill, the cell tag, the mobile picker and the group title now draw
TAG_UNTITLED (com.labre.data-view.tag.untitled, "Untitled") through one
tagLabel helper, the tag menu finds blank options by that label, the
rename fields take it as placeholder, and the group title keeps a
min-height. Display only: the stored option name is never rewritten.
The manifest's chrome dedupe keeps the word apart from the page's
DOC_UNTITLED (a name, not a title).

Port of toeverything/AFFiNE#15807 (a2d1584). Closes #657.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(edgeless): report the stage a framed canvas viewer stalls at

A shell edit had left the deadline's Error without its message, and the
stage it reads unused (TS6133 in yarn build).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(playground): keep the canvas viewer page's status off window.status

The page is a script with no top-level import, so a top-level status
clashed with the global (TS2451 in yarn build).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore: refresh the architecture map for the canvas viewer

The viewer imports the frame-title widget's style constants; the playground
gains its examples/canvas-viewer page.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(database): open a document row from a read-only desktop view

On a read-only page (a public share, a reader, the trash) the desktop
table hid its open button with the title column's editing and a desktop
kanban card attached no click, so a reader could open no row, whereas
the phone table always drew its open chip.

The database block now sets HostOpensRowsKey on its data source when a
DatabaseOpenRowExtension opener is registered (read with getOptional,
null reads as none). opensRowsWhenReadonly (that key and rowsAreDocuments)
keeps the table's open button and gives a read-only kanban card a click
that opens the row through openDetailPanel. Editing chrome stays off and
opening writes nothing; without an opener or over block rows, a read-only
view is unchanged, and the writable paths are untouched.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore(database): refresh the architecture map for the tag label

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(edgeless): ignore a pinch step whose fingers started on one point

The helper ADR 0051 removed guarded the zero distance; the restored inline
handler did not, and divided by it, sending the zoom to Infinity. The
read-only canvas viewer runs this same handler.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(edgeless): name a frozen canvas view and repaint it when page fonts load

A frozen view is a button; with no title it had no accessible name. It is
now named by com.labre.canvas-viewer.open-titled ("Open canvas: {{title}}")
or com.labre.canvas-viewer.open ("Open canvas"), English when the host has
no translation.

The viewer loads no face: a public page declares its own, which may land
after the canvas painted its text. The viewer now repaints the surface on
each document.fonts loadingdone, as the editor does once FontLoaderService's
faces are in, and stops listening when disposed.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: say the canvas viewer repaints when the page's fonts load

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): keep a native modal dialog open when escape closes the icon picker

The picker's Escape listener stopped propagation but left the default
action, and a native `dialog` opened with `showModal()` closes on that
default action, not through a listener. Opening `openIconPicker` with a
`container` inside such a dialog and pressing Escape therefore closed both.
The listener now also calls `preventDefault()`; the spec asserts the
dispatched event is `defaultPrevented` (happy-dom never closes a dialog on
Escape, so a dialog left open there would prove nothing).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): flip a menu toggle row once per click

A `menu.toggleSwitch` row handed `onChange` the value it was built with,
so a click on the row changed nothing, or undid a flip made on the
switch. A click on the switch also reached the row: in a browser the
label click and the checkbox click it sends on both bubbled to the row,
whose `select` wrote twice beside the switch's own `onChange`.

The row's `select` now writes `!on`, and the switch's host stops its
click so only the switch's `onChange` answers it. Independent of the
`ToggleSwitch` change of the code toolbar fix: either can land first.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): bound the steps moving a fractional index key towards b

The loop that moves the key towards b ran once per repetition of a forged
b shaped as a10 followed by 10 repeated N times, quadratic in its length:
a 100 KB order key from a peer or a pasted or imported snapshot froze the
tab for tens of seconds on a table row or column insert. The loop now
stops after MAX_PREFIX_STEPS (16) steps, legitimate keys need at most 3,
and returns the bare middle key, still strictly between a and b; only its
uniqueness under concurrent edits is lost, on a hostile document.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): write no list start past nine digits

The numbered-list typing shortcut accepts any number of digits, so a
list typed as 1234567890 would export as 1234567890. x, which CommonMark
does not read as a list (a start is nine digits at most) and which
re-imports as a paragraph; before the start was written, the same list
came back as a list. listStartNumber now writes a start only up to
999999999, in Markdown and HTML alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(database): open a read-only kanban card from its cells

Every kanban cell stops the click it receives, so the read-only card's
bubbling listener only heard its margin: a reader clicking the title or
a value opened nothing. The card now listens in the capture phase, and
leaves a click on a link (an a[href] in the composed path) to the link.
The writable path is unchanged.

The spec now clicks the title at its centre, a property value and a URL
cell's link, as a reader does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(blocks): leave the column select-and-clear case to the unit spec

The integration spec's column case replayed the unit spec's `setGroup`
path on the same `StoreSelectionExtension`, and its page variant could
not fail on the old code. The spec keeps the case that goes through the
real cell focus and real typing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(blocks): drop the words path from the code toggle unit spec

The row's click handler does not look at its target, so a click on the
words crossed no branch the row path did not; the real click on the
words stays held in Chromium by the code toolbar toggles spec.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(edgeless): say why the export options menu rebuilds its rows

The reason given in the export SVG options menu for `menu.dynamic` no
longer held once a toggle row flips on click: with a static `on`, a
second click on the row writes the same value again and the switch is
not redrawn.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* refactor(edgeless): carry the palette values in the library's theme

The editor-free entries now load zod only. The model's theme read
@toeverything/theme/v2 at run time for getColorByKey, pureBlack and
pureWhite, which put the package in every headless graph once the mind map
styles joined @labre/affine-model/headless. The product owner chose not to
allow it.

themes/palette-values.ts holds the 33 palette entries the theme reads, valued
as getColorByKey valued them, and imports types only. palette-values.unit.spec.ts
holds each entry to the package and the key list to default.ts, so a theme
bump that would move a stored colour fails there. DefaultTheme and Themes are
deep-equal before and after: no stored colour changes.

The interchange-headless guard drops the package from its allow-list and
refuses it by name. ADR 0045: the 2026-11-10 amendment no longer widens the
list; a 2026-11-12 amendment records the copy (red zone, stored format
unchanged).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): keep the random postfix past the fractional index step bound

The bound added on the loop moving a key towards b returned the bare
middle key, the same on every peer. Legitimate keys reach that bound:
inserting again and again right after the first table row needs 32 steps
by the 150th insert, so two peers inserting at the same place got equal
keys, and a later insert between them threw "a should be smaller than b".
Past the bound the key now aims at a, generateKeyBetween(a, middle) plus
'0' and the random postfix: that key is never a prefix of middle, so it
stays below b, and it stays unique. The bare middle remains only as a
guard that the comparison keeps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(edgeless): drop a duplicate type check and a repeated comment

The first builder test already asserts the exact list of element types; the
allow-list's reason is written next to the @toeverything assertion, in
palette-values.ts and in headless.ts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): give a menu toggle row's focus back after a switch click

The switch of a `menu.toggleSwitch` row keeps its click from the row,
so the menu container that gives the focus back to the hidden search
field on a click never saw it: the label's mousedown had taken that
focus, and the arrows and Enter, heard only on that field, went dead.
The switch's `onChange` now hands the focus back through
`menu.focusTo`, wherever the click was stopped.

Also: a `ponytail:` line on `stopSwitchClick` (redundant once
`ToggleSwitch` stops its own click), the table header menu docblock no
longer describes the old row behaviour, and the integration spec takes
`deepQueryAll` from a shared `utils/deep-query.ts`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): give the library an emotion instance of its own

The first version patched the sheet of @emotion/css's default instance at
import and deferred every rule to a microtask, in every editor: a host whose
install deduplicates @emotion/css had its own rules deferred too, broke on a
measure taken right after css(), and could see its cascade order flip.

The library now creates its own instance (createEmotion, key labre: classes
read labre-...). Its rules are inserted synchronously, as before, until
adoptEmotionStyles is called; the default instance is never touched. A spec
holds both.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(edgeless): deny the pages a canvas viewer is not given, free its keys

A page docTitles leaves out may be unshared, private or deleted: the viewer
now names it Access denied (com.labre.canvas-viewer.access-denied, and its
.description for the card sentence) wherever the editor would say deleted,
by wrapping the host's translation, and a click on it calls nothing.
onOpenDoc keeps its untrusted-input note.

A host's link preview service and image proxy reach the blocks through a
provider of viewExtensions; a spec mounts a bookmark with no metadata and
sees the host's service called, no fetch, and the host's proxy in the icon.

The key pause of the dispatcher also ends on the next task: a listener that
stopped a key left the wheel and the drag dead. The viewer adopts the
library's styles from a module it imports first, before any block module
evaluates, now that emotion inserts synchronously.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: canvas viewer pages, host seams and emotion instance

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(database): decide a kanban card's read-only open at the click

The capture listener was attached in connectedCallback from readonly$
read once, but readonly$ flips without remounting the cards: a card
mounted read-only kept opening rows once the page turned writable (a
click meant to edit navigated away), and one mounted writable never
opened once read-only. The listener is now always attached and returns
at once unless the view is read-only and the host opens its rows.

The spec's link guard now listens in the capture phase (the cell stops
the click before it bubbles) and the link points at example.invalid, so
the suite never reaches the network.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): bound the last list number, not the start, on export

remark-stringify numbers a list from its start, so a two-item list at
999999999 exported as 999999999. then 1000000000., and the second line
folded into the first item on import. listStartNumber now takes the walker
node, counts the items the exported list merges (the following siblings
of the same list type) and writes start only when the last number stays
within nine digits. One rule for Markdown and HTML, though an ol start
has no limit, so both exports of a document number it alike.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore: refresh the architecture map for the viewer's styles module

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(blocks): say why the fractional index fallback guard is required

The comment claimed generateKeyBetween(a, middle) is never a prefix of
middle. The midpoint b.slice(0, 1) branch can return one, and that
prefix plus the postfix may sort after b: the a < key < b check is what
keeps the order, not a free extra.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(database): keep a read-only kanban card from opening a row twice

A card mounted writable keeps its editing listeners when the view turns
read-only without a remount. With a card selection left from editing, a
click on its margin opened the row through the read-only capture
listener, then again through the editing one (two host navigations, two
tabs on Ctrl/Cmd); a right click still popped the editing menu. Both
editing listeners now return at once while the view is read-only.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(edgeless): read the host's translation in the canvas viewer's own container

The viewer read the host's translation from a second extension manager built
on the host's providers alone, whose get('edgeless') throws when none of them
registers in that scope (a provider that registers for the page view only,
the ext-loader README's pattern): the mount threw, before the workspace was
freed, and every host extension was set up twice.

The wrapper is now an extension set up after every other one of the viewer's,
the host's included: it reads the translation registered so far in the same
container and overrides it. The extensions are built inside the try that
frees the workspace. The static HTML guide notes that setImageProxyURL also
sets a module-wide URL.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore: refresh the architecture map after the viewer translation fix

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(playground): show the interactive canvas viewer across the whole window

The recette page kept every view in a 960 px column, so the whole canvas
looked like a bug of the viewer. A frozen frame stays in that column, as on
a published page; the interactive view takes the window.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(page): store the outline type-icon switch's own value

The settings menu of the outline panel wrote `!showPreviewIcon`, the
negation of the value read at its last render, instead of the value the
`toggle-switch` hands its `onChange`. Two flips before the menu
re-rendered both wrote the same negation, leaving the switch on and the
stored setting off. The handler now writes the value it receives.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs: record the unpushed host-session work and the browser-start false red

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(database): read a malformed data-view cell as its column default

BlockQueryDataSource.cellValueGet returned the stored cells[rowId][columnId]
raw, with no schema: a value its column rejects (a type change, an import, a
peer on another version) reached the views, where a malformed date or
multi-select threw in stringValue$ and a malformed checkbox rendered ticked.
It now reads as the column's rawValue.default(), at the read boundary only:
the stored value is kept and nothing is written. An absent cell still reads
as undefined, and a column type without a meta passes through unchanged.

Same rule as the affine:database source (#656).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): refuse an oversized fractional index key with a named error

A forged neighbour key such as `a0` followed by twenty thousand `z` made
the midpoint of the fractional-indexing package recurse once per
character and overflow the stack with a RangeError. Keys longer than
4096 characters are now refused before the package is called, with a
BlockSuiteError whose message does not quote the key. The bound sits
under the measured cold-stack overflow (about 5700 characters on V8)
and above every legitimate series measured.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): keep a shadow-held portal open on a click inside it

The click-away listener of `createLitPortal` read `e.target` on the
document, where a click inside a portal held by a shadow tree is
retargeted to a shadow host outside the portal. The zoom bar's vertical
menu, appended inside its toggle button's shadow root, closed after
every "Zoom in". The listener now reads `e.composedPath()`; a click
that is really outside still closes the portal for every caller.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): count only list items when importing an html start number

An indented `<ol start="3">` has a whitespace text node between two
items, and the index added to the start counted it: the items came in
as 4, 6, 8 and the list block shows a stored integer order as is. The
index now counts the `li` element children only. An `<ol>` without a
start keeps a null order, which the list block numbers from 1.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(edgeless): cache a canvas character's width by the whole character

`charWidth` indexed its cache by `charCodeAt(0)`. An emoji is two UTF-16
units and the emojis from U+1F300 to U+1F64F share the first one, so the
first measured in a font lent its width to the others and `wrapText`
cut a word of emojis in the wrong place. A character beyond one unit is
now cached under the whole string; a single-unit character keeps the
indexed array, so a text without emoji measures and wraps as before.
`getCache` still returns that array.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(database): record the virtual table's missing row open button

The virtual table (enable_table_virtual_scroll) has no row open button,
writable or read-only; a row opens only from the context menu, which a
read-only view turns off. A ponytail line on DatabaseCellContainer names
the ceiling and the upgrade: the pc table's row-ops with
opensRowsWhenReadonly the day the flag turns on.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* test(blocks): take every deep shadow query from the shared helper

Five integration specs kept their own copy of `deepQueryAll`, identical
to the one `utils/deep-query.ts` now exports (the paste spec's as an
arrow function with another parameter name). They import it instead.
The selection pane spec keeps its local `deepQuery`, a first-match
variant the helper does not offer.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(blocks): unify the emoji picker on the callout's emoji-mart picker

The library had two neighbouring pickers: the callout's `affine-emoji-menu`
(emoji-mart) and `affine-icon-picker` (its own emoji grid plus the icon
set), which `openIconPicker` opens for hosts. The PO kept the callout's.

`affine-emoji-picker-panel` is now emoji-mart's picker, handed the bundled
`@emoji-mart/data` set and an `i18n` object read through `com.labre.*`
wordings, so it never downloads its dataset or a locale from the CDN (11 new
keys under `com.labre.emoji-picker.*`, the categories reuse
`com.labre.emoji-group.*`). `affine-icon-picker` keeps its Icons tab, gains
`tabs`, and draws neither tab bar nor frame when it offers one tab and no
Remove. The callout opens it with the Emoji tab alone, since `props.emoji`
stores an emoji and nothing else, and writes the pick to the same field.
`openIconPicker` keeps its options, result and Escape handling.

Removed with what they drew: the callout's `EmojiMenu` and its teardown
spec, `emoji-data.ts` (`getEmojiGroups`, `filterEmojiGroups`,
`emojiUnicode`, `SKIN_TONES`, `RECENT_GROUP_NAME`), `RECENT_EMOJIS_KEY`, the
emoji footer styles, and their literal-ratchet entries. `emoji-mart` moves
from the callout's manifest to the components'.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): write list markers in plain text

The plain-text export and the text/plain copy wrote a list as bare
lines. A list item now writes the marker the Markdown export writes
(a dash for a bullet or a toggle, the number from the list's start for
a numbered item, a checkbox for a to-do), and a sub-list is indented by
its parent's marker width, as Markdown indents it. Both paths share
listPlainTextPrefix in affine-shared; other blocks are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): keep frequently used for every emoji picker after one without recents

emoji-mart keeps its categories at module level, shared by every picker of
the page, and its init splices an empty `frequent` category out of that
shared list. An opening with `rememberRecent: false` (`maxFrequentRows: 0`)
emptied it, so a host's `openIconPicker` without recents took the
"Frequently used" row away from every later picker, the callout's
included, until the page reloaded.

The panel now always names emoji-mart's `categories` (with `frequent`
first, or without it when recents are off): given that list, emoji-mart
filters a copy and never splices the shared one. This uses emoji-mart's
public option rather than patching its `Data` from outside. The literal
ratchet's comment no longer cites the deleted `emoji-data.ts`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(database): give a duplicated column its own option ids

DatabaseBlockDataSource.propertyDuplicate copied data.options with the same
option ids and the cells with the same values, so a select or multi-select
column and its copy shared one option identity. The copy now gets fresh
option ids and its duplicated cells are remapped to them; labels, colours
and the source column are untouched. The column and its cells are written
in one transaction (one undo step), and a read-only store writes nothing
(it used to return an id).

copyCellsByProperty gains an optional mapValue argument, identity by
default.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(edgeless): follow a middle click in the canvas viewer through its link checks

The viewer intercepted click, dblclick and keys, but not auxclick: a
middle click on an anchor the canvas renders opened it in a new tab past
isSafeLinkUrl, onLinkClick and docTitles, frozen view included. auxclick
is now captured: a frozen view follows nothing, an interactive one
resolves the link as a click does (a page of docTitles to onOpenDoc, an
external URL normalised and checked, then onLinkClick or a noopener new
tab), and any other button follows nothing.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): open no emoji picker on a read-only callout

A click on a callout's icon opened the picker on a read-only document, and
a pick wrote `props.emoji` into Yjs, which then synced: the gesture never
checked `store.readonly` (before the picker was unified too). The icon now
opens nothing when the store is read-only, and a pick that lands after the
document turned read-only is dropped.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(database): keep a rich-text cell stored as a string in a data-view

The read gate of bbb78fc defaulted every value its column's schema
refuses. The rich-text schema takes a Text only, but a plain string is a
stored shape its readers still read: RichTextCell.beforeEnterEditMode turns
it into a Text keeping its content, toString and the rich-text conversions
read it too. Read as undefined, the first edit wrote an empty Text over it
and a type change converted nothing: the text was lost.

storedShapeIsReadable (database block, properties/stored-shapes.ts) lists
the shapes a reader handles by code written for them; a refused value is
read as stored when listed, as the default otherwise. Rich-text strings are
the only entry: coercing readers (number, checkbox, date) do not count.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(database): keep a rich-text cell stored as a string

The rich-text schema takes a Text only, so getCellValue read a cell stored
as a plain string as the default (undefined, as the code before #656 did).
Its readers still read that shape: RichTextCell.beforeEnterEditMode turns
it into a Text keeping its content, toString and the rich-text conversions
read it too. Read as undefined, the first edit wrote an empty Text over it
and a type change converted nothing: the text was lost.

storedShapeIsReadable (properties/stored-shapes.ts, the same file as on the
data-view branch) lists the shapes a reader handles by code written for
them; a refused value is read as stored when listed. The rich-text toJson
reads a string as its own text instead of walking deltas it lacks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): name the fractional index stack overflow instead of bounding key length

A length bound refused legitimate keys: inserting again and again right
before the same row grows them past 4000 characters after 20000 inserts,
before the package would actually overflow. The package is now called
inside a try: only its RangeError becomes the named BlockSuiteError
(message without the key, the RangeError as cause); any other error goes
up unchanged, and no key is refused for its length.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): separate copied blocks by a newline in plain text

The clipboard writes text/plain from a slice holding one snapshot per
copied top-level block, and MixTextAdapter.fromSliceSnapshot joined
those snapshots with nothing: copying two paragraphs pasted them as one
line elsewhere. They are now separated as the adapter already separates
two blocks of one snapshot, a newline before every block but the first.
A single copied block is unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(database): drag-to-fill a rich-text cell stored as a string

1367140 reads a rich-text cell stored as a plain string as stored, and
its message said the shape's readers read it. Not all of them did:
fillSelectionWithFocusCellData, in the table and the virtual table, took
every rich-text value for a Text. A string source threw on toDelta and
filled nothing; a string target threw on clear and stopped the fill
halfway, the rows after it left as they were.

The source's delta now comes from a Text or a Y.Text, and from toString()
otherwise. A target is cleared and refilled only when it is a Text; any
other value is replaced by a new Y.Text, as an empty cell already was.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): name the fractional index overflow on firefox too

Firefox reports a stack overflow as InternalError (too much recursion),
not RangeError, so the named error now covers both. The spec drops an
assertion that could never fail.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(edgeless): drop the canvas viewer's auxclick branch for other buttons

Only the middle button follows a link on auxclick: the right one goes
through contextmenu, back and forward navigate before auxclick. The
branch cancelling the other buttons had no effect, nor its assertion.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* refactor(blocks): move the list start number rule to affine-shared

The plain-text list markers need the same rule and affine-shared cannot
import the list package, so listStartNumber moves to
affine-shared/adapters/list-start-number.ts, exported from the adapters
entry, and the Markdown and HTML list adapters import it from there.
The docblock now states the rule itself; the parameter only asks for
the node, its parent and its index, which is all it reads.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(blocks): copy the text alone from inside one list item

A selection inside one list item copies that item alone, without
children, and the text/plain copy wrote its marker with the word, so a
word pasted into a field or a terminal came out as "- word". A copy of
one childless list item now writes its text alone; several blocks, or
an item with its sub-items, keep their markers, and the plain-text
export is unchanged.

The start number rule moves to affine-shared/adapters/list-start-number.ts,
byte for byte the module the Markdown and HTML list exports use, instead
of a private copy in list-marker.ts.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* chore: refresh the architecture map for the recette merge

* chore(blocks): drop the switch click stop the toggle fix made redundant

Both toggle fixes are in this branch: <toggle-switch> stops its own click
at its label, so the menu row's extra stop on the switch's host is
redundant, as its ponytail line said. The icon picker's comment no longer
says the portal reads event.target.

Land-Verified: 2026-11-12T22:30:00+01:00 rounds=4
Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: r <r@l>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Mathieu Jolly <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

test Related to test cases

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[Bug]: Inconsistent formats between the exported document and the source document

3 participants