From 781fe86f0e78d0c7bd8ed5fa8f708dc242dd02e9 Mon Sep 17 00:00:00 2001 From: xarmian Date: Sun, 10 May 2026 00:45:49 -0400 Subject: [PATCH] feat(editor): slash menu, toolbar, markdown shortcut to insert htmlBlock (TASK-1326) (#476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(editor): slash menu, toolbar, markdown shortcut to insert htmlBlock (TASK-1326) Three insertion paths for the htmlBlock node, all landing the user in source mode so they can immediately type HTML: 1. **Slash menu** — block-types.ts gets a new SLASH_ITEMS entry (id=htmlBlock, icon='HTML', label='HTML Block', insertOnly=true). execSlash dispatches setHtmlBlock + a requestAnimationFrame click on the empty-state placeholder to enter source mode. 2. **Toolbar** — EditorToolbar.svelte gets an 'HTML' button in the 'blocks' group, after the table button. Same setHtmlBlock + auto-flip pattern. 3. **Markdown shortcut** — htmlBlock.ts adds a new ProseMirror InputRule matching `^```html[\s\n]$`. Replaces the typed text with an empty htmlBlock node. The extension's priority is bumped to 1000 (default 100) so this rule wins against CodeBlock's broader `^```([a-z]+)?[\s\n]$` rule — without this, typing ``` ```html ``` + Enter would create a code block with language=html, not an htmlBlock. The auto-flip-to-source heuristic queries `.html-block:not(.html-block--editing) .html-block-empty` and clicks the preview pane on the next frame after insertion. This works because a freshly inserted block is always empty (empty placeholder visible) and never in --editing mode (--editing is only set when the user explicitly clicks). Multiple new empty blocks would in principle race-flip, but realistically only one is inserted at a time. Out of scope (TASK-1327, TASK-1328 follow): - Hidden-content authoring warning - Diff view collapse Parent: PLAN-1322. * fix(editor): target just-inserted htmlBlock by position + flip from input rule (Codex round 1) * fix(editor): scan for just-inserted htmlBlock + capture editor in input rule (Codex round 2) * fix(editor): capture insertion point before insert + walk forward to find new htmlBlock (Codex round 3) * fix(editor): disambiguate new htmlBlock via before-position snapshot (Codex round 4) * fix(editor): attrs-aware htmlBlock snapshot — handle replace + adjacent cases (Codex round 5) --- web/src/lib/components/editor/Editor.svelte | 15 +- .../components/editor/EditorToolbar.svelte | 11 ++ web/src/lib/components/editor/block-types.ts | 1 + .../components/editor/extensions/htmlBlock.ts | 143 +++++++++++++++++- 4 files changed, 168 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/editor/Editor.svelte b/web/src/lib/components/editor/Editor.svelte index 04f352aa..5b1ecd81 100644 --- a/web/src/lib/components/editor/Editor.svelte +++ b/web/src/lib/components/editor/Editor.svelte @@ -334,7 +334,7 @@ import { workspaceStore } from '$lib/stores/workspace.svelte'; import { api } from '$lib/api/client'; import { BlockDragHandle } from './block-drag-handle'; - import { HtmlBlock } from './extensions/htmlBlock'; + import { HtmlBlock, captureHtmlBlockSnapshot, flipHtmlBlockToSource } from './extensions/htmlBlock'; import { SLASH_ITEMS } from './block-types'; import { AttachmentImage, @@ -443,6 +443,19 @@ case 'orderedList': c.toggleOrderedList().run(); break; case 'taskList': c.toggleTaskList().run(); break; case 'codeBlock': c.toggleCodeBlock().run(); break; + case 'htmlBlock': { + // Snapshot existing htmlBlock (pos, html) entries before + // insertion so flipHtmlBlockToSource can identify the new + // block by elimination — handles all cases including + // NodeSelection-replace (after.length === before.length + // but the replaced entry's html content differs). + if (!editor) break; + const before = captureHtmlBlockSnapshot(editor); + const insertionPoint = editor.state.selection.from; + c.setHtmlBlock({ html: '' }).run(); + flipHtmlBlockToSource(editor, insertionPoint, before); + break; + } case 'blockquote': c.toggleBlockquote().run(); break; case 'horizontalRule': c.setHorizontalRule().run(); break; case 'table': c.insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); break; diff --git a/web/src/lib/components/editor/EditorToolbar.svelte b/web/src/lib/components/editor/EditorToolbar.svelte index b164391b..4a2f4637 100644 --- a/web/src/lib/components/editor/EditorToolbar.svelte +++ b/web/src/lib/components/editor/EditorToolbar.svelte @@ -1,6 +1,7 @@ diff --git a/web/src/lib/components/editor/block-types.ts b/web/src/lib/components/editor/block-types.ts index 06c97d66..458c0b55 100644 --- a/web/src/lib/components/editor/block-types.ts +++ b/web/src/lib/components/editor/block-types.ts @@ -23,6 +23,7 @@ export const BLOCK_TYPES: BlockType[] = [ { id: 'orderedList', icon: '1.', label: 'Numbered List', description: 'Ordered list' }, { id: 'taskList', icon: '☐', label: 'Checklist', description: 'Task list' }, { id: 'codeBlock', icon: '<>', label: 'Code Block', description: 'Fenced code block' }, + { id: 'htmlBlock', icon: 'HTML', label: 'HTML Block', description: 'Sanitized HTML embed (live preview)', insertOnly: true }, { id: 'blockquote', icon: '❝', label: 'Blockquote', description: 'Quote block' }, { id: 'horizontalRule', icon: '——', label: 'Divider', description: 'Horizontal rule', insertOnly: true }, { id: 'table', icon: '⊞', label: 'Table', description: '3×3 table', insertOnly: true }, diff --git a/web/src/lib/components/editor/extensions/htmlBlock.ts b/web/src/lib/components/editor/extensions/htmlBlock.ts index 404f477a..d8c5c64b 100644 --- a/web/src/lib/components/editor/extensions/htmlBlock.ts +++ b/web/src/lib/components/editor/extensions/htmlBlock.ts @@ -15,11 +15,107 @@ * collapse in TASK-1328. */ -import { Node } from '@tiptap/core'; +import { InputRule, Node, type Editor } from '@tiptap/core'; import type MarkdownIt from 'markdown-it'; import type { Node as ProseMirrorNode } from '@tiptap/pm/model'; import { sanitizeHtmlBlock } from '$lib/utils/markdown'; +/** A single htmlBlock node's identity for snapshot comparison. */ +export interface HtmlBlockSnapshotEntry { + pos: number; + html: string; +} + +/** + * Snapshot every htmlBlock node currently in the editor, recording + * BOTH position and `attrs.html` content. The caller pairs this with + * `flipHtmlBlockToSource` to disambiguate a just-inserted (or + * just-replaced) block from any pre-existing ones. + * + * Capturing content (not just position) is what makes the helper + * correct in the "NodeSelection replaces an existing htmlBlock" case + * — `insertContent` over a NodeSelection swaps the existing block + * for the new one, leaving `after.length === before.length` but with + * the replaced block's content changed (commonly to ''). A + * position-only snapshot can't see that change; the content snapshot + * does. + */ +export function captureHtmlBlockSnapshot(editor: Editor): HtmlBlockSnapshotEntry[] { + const snapshot: HtmlBlockSnapshotEntry[] = []; + editor.state.doc.descendants((node, pos) => { + if (node.type.name !== 'htmlBlock') return; + const html = typeof node.attrs.html === 'string' ? (node.attrs.html as string) : ''; + snapshot.push({ pos, html }); + }); + return snapshot; +} + +/** + * After inserting an htmlBlock node, defer one frame and synthesise a + * click on the new block's preview pane so the user lands directly in + * source mode (matches the spec: all three insertion paths land in + * source). + * + * Identifies the new block by walking the post-dispatch htmlBlock + * snapshot in document order and matching each entry against the + * before-snapshot. A pre-existing block matches an after entry when: + * + * - Their `html` content is identical, AND + * - The position is plausibly the before position shifted by + * ProseMirror's transaction mapping. Tolerance window: + * 0 → block was before the insertion point (unshifted) + * 1 → atom-block insertion shifts later positions by +1 + * 3 → mid-paragraph split adds 2 paragraph tokens + 1 atom = +3 + * -1 → empty-paragraph collapse drops a paragraph token = -1 + * + * The first after entry that *doesn't* match a before entry is the + * inserted (or replaced) block. This works uniformly for: + * - Plain insert at empty paragraph / end of paragraph / mid-paragraph + * - Insert adjacent to an existing htmlBlock + * - Insert that replaces a NodeSelection on an existing htmlBlock + * (after.length === before.length but the replaced entry's html + * differs from its before image) + * + * Silent no-op if no new block is found (e.g. the insert failed). + */ +export function flipHtmlBlockToSource( + editor: Editor, + insertionPoint: number, + before: HtmlBlockSnapshotEntry[], +): void { + requestAnimationFrame(() => { + const { state, view } = editor; + const after = captureHtmlBlockSnapshot(editor); + + let bi = 0; + let newPos: number | null = null; + for (const a of after) { + let matched = false; + if (bi < before.length) { + const b = before[bi]; + const shift = a.pos - b.pos; + const isUnshifted = b.pos < insertionPoint && shift === 0; + const isShifted = + b.pos >= insertionPoint && (shift === -1 || shift === 1 || shift === 3); + if ((isUnshifted || isShifted) && a.html === b.html) { + bi++; + matched = true; + } + } + if (!matched) { + newPos = a.pos; + break; + } + } + if (newPos === null) return; + + const dom = view.nodeDOM(newPos) as HTMLElement | null; + if (!dom || !dom.classList.contains('html-block')) return; + const previewPane = dom.querySelector('.html-block-preview') as HTMLElement | null; + previewPane?.click(); + }); +} + declare module '@tiptap/core' { interface Commands { htmlBlock: { @@ -53,6 +149,11 @@ export const HtmlBlock = Node.create({ selectable: true, draggable: true, defining: true, + // Higher than the default extension priority (100) so the markdown + // shortcut input rule below fires BEFORE CodeBlock's `^```([a-z]+)?…` + // rule. Without this, typing ` ```html ` would create a code block + // (language=html) instead of an htmlBlock node. + priority: 1000, addAttributes() { return { @@ -274,6 +375,46 @@ export const HtmlBlock = Node.create({ }; }, + addInputRules() { + // Markdown shortcut: typing ` ```html ` followed by a space or newline + // at the start of an empty paragraph creates a new htmlBlock node. + // Mirrors CodeBlock's textblockTypeInputRule pattern but materialises + // an atom node (the htmlBlock leaf) instead of a wrapped textblock. + // Higher extension priority (1000, set above) ensures this fires + // before CodeBlock's broader `^```([a-z]+)?…` rule. + // + // `this.editor` is captured via lexical scope — TipTap's InputRule + // handler config does NOT pass `editor` directly. By the time the + // rule fires, the extension instance has been bound to the editor. + const extension = this; + return [ + new InputRule({ + find: /^```html[\s\n]$/, + handler: ({ state, range }) => { + // Snapshot pre-dispatch htmlBlock entries (pos + html) + // so the flip helper can disambiguate the new block + // from any existing ones, including the replace-existing + // case where after.length === before.length. state.doc + // here is the pre-dispatch document. + const before: HtmlBlockSnapshotEntry[] = []; + state.doc.descendants((node, pos) => { + if (node.type.name !== 'htmlBlock') return; + const html = typeof node.attrs.html === 'string' ? (node.attrs.html as string) : ''; + before.push({ pos, html }); + }); + state.tr.replaceRangeWith( + range.from, + range.to, + extension.type.create({ html: '' }), + ); + if (extension.editor) { + flipHtmlBlockToSource(extension.editor, range.from, before); + } + }, + }), + ]; + }, + addStorage() { return { markdown: {