feat(editor): slash menu, toolbar, markdown shortcut to insert htmlBlock (TASK-1326) (#476)

* 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)
This commit is contained in:
xarmian
2026-05-10 00:45:49 -04:00
committed by GitHub
parent c02aedd4ac
commit 781fe86f0e
4 changed files with 168 additions and 2 deletions
+14 -1
View File
@@ -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;
@@ -1,6 +1,7 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { editorStore } from '$lib/stores/editor.svelte';
import { captureHtmlBlockSnapshot, flipHtmlBlockToSource } from './extensions/htmlBlock';
let { editor }: { editor: Editor | null } = $props();
@@ -30,6 +31,16 @@
btn('""', () => editor!.chain().focus().toggleBlockquote().run(), editor.isActive('blockquote')),
btn('──', () => editor!.chain().focus().setHorizontalRule().run(), false),
btn('⊞', () => editor!.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(), false),
btn('HTML', () => {
// Snapshot existing htmlBlock (pos, html) entries before
// insertion so flipHtmlBlockToSource can disambiguate the
// new block from any pre-existing ones — handles cursor-
// adjacent-to-existing AND NodeSelection-replace cases.
const before = captureHtmlBlockSnapshot(editor!);
const insertionPoint = editor!.state.selection.from;
editor!.chain().focus().setHtmlBlock({ html: '' }).run();
flipHtmlBlockToSource(editor!, insertionPoint, before);
}, false),
]},
] : []);
</script>
@@ -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 },
@@ -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<ReturnType> {
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: {