mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 18:13:26 +00:00
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:
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user