Skip to content

Commit 387cdbf

Browse files
committed
feat(core): place a block view after its block
- `BlockView.placement: 'after'` keeps the block as the editor draws it, editable, and places the view's element after it, like a widget, with the same lifetime and reveal rule as a replacing view: for UI that adds to the text instead of standing in for it, such as a preview card for a link that sits on its own line of a paragraph. - `inlineLines(content)` gives the spans between a block's line breaks, so a host can match one line. The line stays a span and the parser never splits the paragraph; the architecture doc records why.
1 parent 33a7fa8 commit 387cdbf

9 files changed

Lines changed: 238 additions & 14 deletions

File tree

‎README.md‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,8 @@ math({ render: (tex, element, options) => katex.render(tex, element, { ...option
251251

252252
`blockView` draws whole blocks the app wants to show its own way, such as a paragraph that is one bare link shown as a preview card with data from the app's server. `match(block)` picks the blocks, and `render(block, element, { editor })` draws one into `element`, a div the editor owns and keeps while the block is unchanged: typing elsewhere, a new selection, `redecorate()` and a switch to read-only never call `render` again, so app UI mounted into the element, such as a React portal, stays. `render` runs again for a block whose content changed and for a new block, and may return a function, which the editor calls when it lets the element go: the block was removed or changed, `refreshViews()` ran, or the editor was destroyed. Returning `false` leaves the block as the editor draws it. While editing, the block shows as the editor draws it, editable, when the selection is in it, and a click on the view puts the caret there, as for a formula; read-only always shows the view. `refreshViews(name)` with the extension's name draws its blocks again.
253253

254+
`placement: 'after'` keeps the block as the editor draws it, editable, and places the element after it instead, like a widget: for UI that adds to the text rather than standing in for it, such as a card for a link that sits on its own line of a paragraph. `inlineLines(content)` gives a block's lines, the spans between its line breaks, so `match` can look at one line. The element is still withheld while the selection is in the block, so a link being typed is not previewed on every keystroke, and clicks on it are the app's, not the editor's.
255+
254256
```tsx
255257
const linkCards: Extension = {
256258
name: 'linkCards',
@@ -263,6 +265,22 @@ const linkCards: Extension = {
263265
},
264266
},
265267
};
268+
269+
// A card below a paragraph for each line of it that is one bare link (with `markdown: { breaks: true }`).
270+
const bareLink = (line: Inline) => (line.length === 1 && line[0].link === line[0].text ? line[0].link : undefined);
271+
const linkLines = (block: Block) => (block.type === 'paragraph' ? inlineLines(block.content).map(bareLink).filter((href) => href !== undefined) : []);
272+
const linkCardsBelow: Extension = {
273+
name: 'linkCardsBelow',
274+
blockView: {
275+
placement: 'after',
276+
match: (block) => linkLines(block).length > 0,
277+
render: (block, element) => {
278+
const root = createRoot(element);
279+
root.render(linkLines(block).map((href) => <LinkCard key={href} href={href} />));
280+
return () => root.unmount();
281+
},
282+
},
283+
};
266284
```
267285

268286
### Theming

‎docs/architecture.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ That is 64 places and 45 distinct icons. `chevronDown`, `chevronRight`, `selecte
257257
- **Key bindings** (`keymap`): see "Input pipeline".
258258
- **Decorations** (`decorations`): a function from one text container (a paragraph, heading, list item, quote or table cell) to CSS classes on ranges of its text. The context lists the ranges of inline code, links, images and tokens, which `textDecorator` skips. Decorations are computed while rendering a block, so they only run for blocks that changed, and they are wrapped in `span.mbe-deco` elements that the DOM-to-model mapping walks through like mark elements. `codeDecorations` does the same for the code of a code block (syntax highlighting); other decorators never see code blocks. `editor.redecorate()` re-renders every block with fresh decorations, after the composition ends if one is running, for decorations that read state outside the document.
259259
- **Views** (`views`): a function that renders the source of a token or raw block of one syntax into an element the renderer provides (a span or a div). A token with a view renders as one `contenteditable="false"` atom that counts as many characters as its source (`data-source` holds it), so DOM-to-model mapping and the IME read-back still see the source. Inline raw HTML has no token, so the `html` view takes runs instead (`htmlRuns` in `view/dom.ts`): an opening tag span through its matching closing tag span in the same container, or one tag span alone; a run is one atom as long as its text, and it reveals like a token. The view gets only the run's tags, with a slot marker (`slotMarker`, private-use characters) where text between them was; the renderer renders that text itself, with its marks, decorations and tokens, and puts it where the view left the marker (`fillSlots`). An HTML section renders the same way (`splitHtmlSection`): its source is parsed again, the HTML blocks are joined with one marker per run of Markdown blocks, and each run renders as the editor's blocks in a `.mbe-flow` container. Those blocks are content of the section, not blocks of the document: the renderer strips their block ids, content markers and `contenteditable`, and makes their inputs inert. A marker that a Markdown-rendering view wraps in a paragraph replaces the paragraph; a slot whose marker the view dropped follows the rest. The stylesheet's block rules select `:is(.mbe-root, .mbe-flow) >`, so section blocks look like top-level ones, and a re-rendered section keeps which `<details>` the reader opened. Raw Markdown blocks (`syntax: 'markdown'`) have a built-in view, `MARKDOWN_VIEW` in `view/dom.ts`, that the renderer draws itself with the editor's blocks (`renderMarkdown`): it parses the source again, renders each run of nodes the model can hold as blocks, and draws a quote or list item the model cannot hold as a container (`blockquote.mbe-quote`, or `.mbe-list-item` with its marker and a `.mbe-flow` of its blocks), recursively. Like an HTML section's Markdown, these blocks are content, not blocks of the document. An extension's `markdown` view replaces it. A raw block with a view renders as a `contenteditable="false"` block with no content element, like a divider, and so does a code block whose language has a `code:<language>` view; both reveal their source the same way. A view may return a promise: the renderer caches the element it filled in at once, records which blocks show it, and when the promise settles asks the editor to render those blocks again (on the next frame, or when a composition settles), from the finished element or, after a rejection, as source. Which views show their source is a pure function of the state (`view/reveal.ts`): a token that either end of the selection touches (edges included) and a raw block that holds either end, only while editable and focused. The renderer keeps each block's revealed views next to its element and rebuilds a block when they change, the same way it rebuilds a block whose object changed. Every render goes through `Editor.paint`, which recomputes them; selection, focus and editability changes call `refreshRevealed`, which paints only when they differ. While a mouse button is down nothing changes until mouseup, so a drag is not disturbed. A click on a view puts the caret in its source, and an arrow key that would skip a rendered block steps into its source instead. Rendered elements are cached by source (least recently used, 500 entries) and cloned, so typing next to a formula does not re-run the renderer; listeners a view attaches are lost in the clone, so a view's interaction is CSS or a listener on `editor.root` from `onCreate`. A view gets the editor as its last argument: its element is not in the page when it runs, so a view that lays out text (a diagram) reads the font from `editor.root` and measures on a canvas. The renderer notes which blocks showed each cached, failed or declined view (`viewBlocks`); `dropViews(syntax?)` forgets those views and returns the blocks, and `Editor.refreshViews(syntax?)` renders only them again (after a composition, through `unsettled`, as settled promises are). A promise still pending when its view is dropped settles into nothing: a later render of the same source owns the cache key. The editor refreshes every view itself when a theme change touches a font token (`FONT_PROPERTIES`), since the colors are CSS variables but a measured layout is not.
260-
- **Block views** (`blockView`): an extension draws whole blocks of the document that its `match` picks, for UI that needs data from outside the document, such as a bare link shown as a preview card. Unlike a syntax view, the result is not cached by source and cloned: the renderer keeps one element per block ID (`hosted` in `BlockRenderer`) while the block object is the same, and hands the same element back on every render of that block, so app UI mounted into it (a React portal) survives renders the block itself did not cause. The element is the child of a fresh `div.mbe-block.mbe-view-block.mbe-block-view` root, which carries the editor's own classes and attributes, so the app's element is its own. A block object that changed (its content) gets a new element, and the old one's dispose function (what `render` returned) runs; so does a block that is gone after a render, every block on `dropViews` (so `refreshViews(name)`, with the extension's name, draws them again) and on destroy. A view that declined (`false`) is remembered for that block object, so it is not asked again until the block changes. A hosted block reveals like a raw block with a view: `revealedViews` marks it `REVEAL_BLOCK` when the selection is in it, and it then renders as the editor draws it, editable, while the element waits detached; `viewEntry` puts the caret at its start or end. Blocks inside an HTML section or raw Markdown view are content, not blocks of the document, and never go through a block view, nor does source mode's text block.
260+
- **Block views** (`blockView`): an extension draws whole blocks of the document that its `match` picks, for UI that needs data from outside the document, such as a bare link shown as a preview card. Unlike a syntax view, the result is not cached by source and cloned: the renderer keeps one element per block ID (`hosted` in `BlockRenderer`) while the block object is the same, and hands the same element back on every render of that block, so app UI mounted into it (a React portal) survives renders the block itself did not cause. The element is the child of a fresh `div.mbe-block.mbe-view-block.mbe-block-view` root, which carries the editor's own classes and attributes, so the app's element is its own. A block object that changed (its content) gets a new element, and the old one's dispose function (what `render` returned) runs; so does a block that is gone after a render, every block on `dropViews` (so `refreshViews(name)`, with the extension's name, draws them again) and on destroy. A view that declined (`false`) is remembered for that block object, so it is not asked again until the block changes. A hosted block reveals like a raw block with a view: `revealedViews` marks it `REVEAL_BLOCK` when the selection is in it, and it then renders as the editor draws it, editable, while the element waits detached; `viewEntry` puts the caret at its start or end. Blocks inside an HTML section or raw Markdown view are content, not blocks of the document, and never go through a block view, nor does source mode's text block. With `placement: 'after'` the block renders as the editor draws it and the element follows it in a `div.mbe-block-view.mbe-block-after` wrapper, `contenteditable="false"`, placed by the renderer between the block's element and its widgets (`afterView`), with the same lifetime and the same reveal rule as a replacing view: while the selection is in the block the wrapper waits detached, so a link being typed is not previewed on every keystroke, and when the selection leaves the same element is placed again. The block's own element never carries `mbe-view-block`, so the caret moves through it as through any paragraph, and the wrapper is app UI like a widget: events from it are not editor input (`APP_UI` in `editor.ts`), and a click on it reveals nothing. This is the editor's answer to a link on its own line of a paragraph: the line stays a span (a card is block-level, and a span has no identity to keep an element by), the parser never splits the paragraph (the model would then disagree with every other renderer, and a save after an edit would turn the line break into a blank line), and the app finds the line with `inlineLines`, which splits inline content at `"\n"`.
261261
- **Widgets** (`addWidget`): elements placed after a block element by the renderer, `contenteditable="false"`, and ignored by input handling. A widget follows its block's ID; when a change removes that block, the widget moves to the nearest earlier remaining block (after `setMarkdown`, the block at the same index). Widgets are not in the document, so they are never saved and undo does not touch them.
262262
- **Paste and drop** (`handlePaste`, `handleDrop`): run before the editor's own handling. `fileHandler` is built on them. Dropped text with no handler is parsed as Markdown and inserted at the drop position.
263263
- **Commands** are pure functions `(state) => state` in `commands.ts`; `editor.run(fn)` applies any of them, and `editor.commands` binds the common ones. `run` treats a command that rebuilt the block list without changing any block as a no-op, so `commands.*` return false when nothing changed. Two of them carry editing rules an app would otherwise reimplement from model details: `insertFragment` with `block: true` replaces an empty paragraph at the target instead of leaving an empty line above the inserted blocks, and `insertToken` inserts `#tag`-like text at the selection's focus spaced as a word, reusing a space that follows; `commands.insertToken` also refuses during IME composition, when an insert would break the composed text.

‎e2e/views.spec.ts‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,111 @@ test('a block view draws matching blocks into an element kept while the block is
492492
expect((await log()).slice(-1)).toEqual(['dispose card declined']);
493493
});
494494

495+
test('a block view placed after its block keeps the block editable and follows it, hidden while the selection is in it', async ({ page }) => {
496+
await page.goto('/?empty');
497+
await page.evaluate(() => {
498+
const { Editor, inlineLines, inlineText } = (window as any).core;
499+
const w = window as any;
500+
w.log = [] as string[];
501+
w.editor.destroy();
502+
// A line that is one bare link, with the `breaks` dialect: what memos previews as a card.
503+
const linksOf = (block: any): string[] =>
504+
block.type === 'paragraph'
505+
? inlineLines(block.content)
506+
.filter((line: any[]) => line.length > 0 && line.every((s) => s.link === line[0].link) && inlineText(line) === line[0].link)
507+
.map((line: any[]) => line[0].link)
508+
: [];
509+
w.editor = new Editor({
510+
element: document.getElementById('editor'),
511+
content: 'intro\n\ntest\nhttps://example.com\nmore\n\nend',
512+
markdown: { breaks: true },
513+
extensions: [
514+
{
515+
name: 'cards',
516+
blockView: {
517+
placement: 'after',
518+
match: (block: any) => linksOf(block).length > 0,
519+
render: (block: any, el: HTMLElement) => {
520+
const links = linksOf(block).join(' ');
521+
w.log.push(`render ${links}`);
522+
el.className = 'card';
523+
el.textContent = `card: ${links}`;
524+
return () => w.log.push(`dispose ${links}`);
525+
},
526+
},
527+
},
528+
],
529+
});
530+
});
531+
const log = () => page.evaluate(() => (window as any).log);
532+
const classes = () => page.locator('#editor > *').evaluateAll((els) => els.map((el) => el.className.split(' ')[0]));
533+
const cards = page.locator('#editor .mbe-block-after .card');
534+
// The paragraph is drawn as usual, with the card's wrapper right after it, and the view ran once.
535+
await expect(cards).toHaveText(['card: https://example.com']);
536+
await expect(page.locator('#editor > p')).toHaveCount(3);
537+
await expect(page.locator('#editor > p + .mbe-block-after .card')).toHaveCount(1);
538+
expect(await classes()).toEqual(['mbe-block', 'mbe-block', 'mbe-block-view', 'mbe-block']);
539+
expect(await page.locator('#editor .mbe-block-after').getAttribute('contenteditable')).toBe('false');
540+
expect(await log()).toEqual(['render https://example.com']);
541+
542+
// Typing elsewhere, read-only and a selection change keep the element.
543+
const card = await cards.elementHandle();
544+
await page.locator('#editor > p').first().click();
545+
await page.keyboard.type('!');
546+
await page.evaluate(() => {
547+
(window as any).editor.setOptions({ editable: false });
548+
(window as any).editor.setOptions({ editable: true });
549+
});
550+
await expect(cards).toHaveText(['card: https://example.com']);
551+
expect(await card!.evaluate((el) => el.isConnected)).toBe(true);
552+
expect(await log()).toHaveLength(1);
553+
554+
// The selection in the paragraph withholds the card; leaving shows the same element again.
555+
await page.locator('#editor > p').nth(1).click({ position: { x: 4, y: 4 } });
556+
await expect(cards).toHaveCount(0);
557+
await expect(page.locator('#editor > p')).toHaveCount(3);
558+
await page.keyboard.press('ArrowUp');
559+
await expect(cards).toHaveText(['card: https://example.com']);
560+
expect(await card!.evaluate((el) => el.isConnected)).toBe(true);
561+
expect(await log()).toHaveLength(1);
562+
563+
// Editing the paragraph lets the element go, and the view draws the changed block once the selection leaves.
564+
await page.locator('#editor > p').nth(1).click({ position: { x: 4, y: 4 } });
565+
await page.keyboard.press('Home');
566+
await page.keyboard.type('x');
567+
await page.keyboard.press('ArrowUp');
568+
await expect(cards).toHaveText(['card: https://example.com']);
569+
expect(await card!.evaluate((el) => el.isConnected)).toBe(false);
570+
expect(await log()).toEqual(['render https://example.com', 'dispose https://example.com', 'render https://example.com']);
571+
expect(await md(page)).toBe('intro!\n\nxtest\nhttps://example.com\nmore\n\nend');
572+
573+
// A click on the card is app UI, not a click into the text: the card stays.
574+
await cards.click();
575+
await expect(cards).toHaveText(['card: https://example.com']);
576+
577+
// A paragraph with no link line gets no card; refreshViews draws the card again; destroy lets it go.
578+
await page.locator('#editor > p').nth(1).click({ position: { x: 4, y: 4 } });
579+
await page.keyboard.press('ArrowDown');
580+
await page.keyboard.press('End');
581+
await page.keyboard.type(' tail');
582+
await page.keyboard.press('ArrowUp');
583+
await page.keyboard.press('ArrowUp');
584+
await expect(cards).toHaveCount(0);
585+
expect((await log()).slice(-1)).toEqual(['dispose https://example.com']);
586+
await page.keyboard.press('ArrowDown');
587+
await page.keyboard.press('ArrowDown');
588+
await page.keyboard.press('End');
589+
for (let i = 0; i < ' tail'.length; i++) await page.keyboard.press('Backspace');
590+
await page.keyboard.press('ArrowUp');
591+
await page.keyboard.press('ArrowUp');
592+
await expect(cards).toHaveText(['card: https://example.com']);
593+
await page.evaluate(() => (window as any).editor.refreshViews('cards'));
594+
await expect(cards).toHaveText(['card: https://example.com']);
595+
expect((await log()).slice(-3)).toEqual(['render https://example.com', 'dispose https://example.com', 'render https://example.com']);
596+
await page.evaluate(() => (window as any).editor.destroy());
597+
expect((await log()).slice(-1)).toEqual(['dispose https://example.com']);
598+
});
599+
495600
test('a font token change renders views again; a color change does not', async ({ page }) => {
496601
await page.goto('/?empty');
497602
await page.evaluate(() => {

‎packages/editor/src/base.css‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,8 @@
253253
:is(.mbe-root, .mbe-flow) > .mbe-view-block > * { margin-block: 0; }
254254
/* A block an extension's block view draws (see Extension.blockView): the app owns what is inside, so nothing is clipped. */
255255
.mbe-root > .mbe-block-view { overflow: visible; }
256+
/* A block view placed after its block, which stays editable text: not text itself, like a widget. */
257+
.mbe-root > .mbe-block-after { cursor: default; white-space: normal; }
256258
/* Tags and mentions with an onClick, read-only: clickable, as links are. */
257259
.mbe-root.mbe-readonly :is([data-mbe-tag], [data-mbe-mention]) { cursor: pointer; }
258260
/* Rendered HTML: line breaks between its tags are not text. The Markdown

0 commit comments

Comments
 (0)