diff --git a/web/src/lib/components/editor/Editor.svelte b/web/src/lib/components/editor/Editor.svelte index fe1bf903..04f352aa 100644 --- a/web/src/lib/components/editor/Editor.svelte +++ b/web/src/lib/components/editor/Editor.svelte @@ -1300,6 +1300,78 @@ border-radius: 0 !important; } + /* HTML blocks (inline via NodeView — TASK-1325 / PLAN-1322) */ + .editor-content :global(.html-block) { + position: relative; + margin: 0.8em 0; + background: var(--bg-tertiary); + border-radius: var(--radius); + overflow: hidden; + transition: outline 0.12s; + } + .editor-content :global(.html-block--editing) { + outline: 1px solid var(--accent-blue); + outline-offset: 0; + } + .editor-content :global(.html-block-preview) { + padding: var(--space-3); + cursor: text; + } + .editor-content :global(.html-block--editing .html-block-preview) { + display: none; + } + .editor-content :global(.html-block-empty) { + color: var(--text-muted); + font-style: italic; + font-family: var(--font-mono); + font-size: 0.9em; + } + .editor-content :global(.html-block-source) { + display: none; + padding: var(--space-2); + } + .editor-content :global(.html-block--editing .html-block-source) { + display: block; + } + .editor-content :global(.html-block-source-input) { + width: 100%; + min-height: 120px; + max-height: 60vh; + padding: var(--space-2); + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + font-family: var(--font-mono); + font-size: 0.9em; + line-height: 1.5; + resize: vertical; + box-sizing: border-box; + } + .editor-content :global(.html-block-source-input:focus) { + outline: none; + border-color: var(--accent-blue); + } + .editor-content :global(.html-block-actions) { + display: flex; + justify-content: flex-end; + margin-top: var(--space-2); + } + .editor-content :global(.html-block-done-btn) { + padding: 4px 12px; + font-size: 0.85em; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + color: var(--text-secondary); + cursor: pointer; + transition: color 0.12s, border-color 0.12s; + } + .editor-content :global(.html-block-done-btn:hover) { + color: var(--text-primary); + border-color: var(--accent-blue); + } + /* Mobile keyboard toolbar */ diff --git a/web/src/lib/components/editor/extensions/htmlBlock.ts b/web/src/lib/components/editor/extensions/htmlBlock.ts index afa54fac..404f477a 100644 --- a/web/src/lib/components/editor/extensions/htmlBlock.ts +++ b/web/src/lib/components/editor/extensions/htmlBlock.ts @@ -84,33 +84,177 @@ export const HtmlBlock = Node.create({ }, addNodeView() { - return ({ node }) => { + return ({ node, editor, getPos }) => { const wrapper = document.createElement('div'); wrapper.className = 'html-block'; wrapper.setAttribute('data-pad-html-block', ''); // contenteditable=false: atom: true means the user can't edit // the rendered preview character-by-character. Editing flows - // through TASK-1325's source-view UI. + // through the source-view textarea below. wrapper.setAttribute('contenteditable', 'false'); + // Preview pane — sanitized live HTML. + const preview = document.createElement('div'); + preview.className = 'html-block-preview'; + + // Source pane — raw HTML editor. Hidden in CSS until + // `.html-block--editing` is set on the wrapper. + const source = document.createElement('div'); + source.className = 'html-block-source'; + + const textarea = document.createElement('textarea'); + textarea.className = 'html-block-source-input'; + textarea.spellcheck = false; + textarea.setAttribute('aria-label', 'Edit raw HTML for this block'); + + const actions = document.createElement('div'); + actions.className = 'html-block-actions'; + + const doneBtn = document.createElement('button'); + doneBtn.type = 'button'; + doneBtn.className = 'html-block-done-btn'; + doneBtn.textContent = 'Done'; + doneBtn.title = 'Save and return to preview (⌘/Ctrl+Enter or Esc)'; + + actions.append(doneBtn); + source.append(textarea, actions); + wrapper.append(preview, source); + let lastHtml = (node.attrs.html as string | undefined) ?? ''; - wrapper.innerHTML = sanitizeHtmlBlock(lastHtml); + let mode: 'preview' | 'source' = 'preview'; + + const renderPreview = () => { + if (!lastHtml.trim()) { + // Empty block: show a placeholder so the user can find it + // and click into source mode. Without this, an empty block + // is an invisible atom and effectively unreachable. + preview.innerHTML = + 'Empty HTML block — click to edit'; + } else { + preview.innerHTML = sanitizeHtmlBlock(lastHtml); + } + }; + renderPreview(); + + function flipToSource() { + if (mode === 'source') return; + mode = 'source'; + textarea.value = lastHtml; + wrapper.classList.add('html-block--editing'); + // Defer focus to the next frame so the click that triggered + // the flip finishes processing (otherwise some browsers swallow + // the focus call mid-event). + requestAnimationFrame(() => { + textarea.focus(); + // Place caret at end of content for natural editing flow. + const len = textarea.value.length; + textarea.setSelectionRange(len, len); + }); + } + + function commit() { + const next = textarea.value; + const pos = typeof getPos === 'function' ? getPos() : null; + if (typeof pos !== 'number') return; + if (next === lastHtml) return; + const tr = editor.view.state.tr.setNodeMarkup(pos, undefined, { html: next }); + editor.view.dispatch(tr); + // `update()` will fire when the dispatched transaction lands, + // updating lastHtml and re-rendering the preview. + } + + function flipToPreview() { + if (mode === 'preview') return; + mode = 'preview'; + wrapper.classList.remove('html-block--editing'); + // Defensive re-render in case lastHtml was the same as + // textarea.value (commit was a no-op) — preview state needs + // to reflect lastHtml regardless. + renderPreview(); + } + + function commitAndFlip() { + commit(); + flipToPreview(); + } + + preview.addEventListener('click', (e) => { + // Read-only viewers must not enter source mode — the + // commit() dispatch would mutate the local document and + // trigger save attempts. Preview-only is the right UX + // in that case. + if (!editor.isEditable) return; + // Don't flip if the user clicked an interactive element + // inside the rendered preview (links, iframes, embedded + // form controls). Those are part of the legitimate use case + // and should respond to clicks naturally. + const target = e.target as Element | null; + if (target?.closest('a, button, iframe, input, textarea, select, video, audio')) { + return; + } + flipToSource(); + }); + + textarea.addEventListener('blur', () => { + // Blur fires both when the user clicks outside AND when the + // Done button click triggers commitAndFlip. The handler is + // idempotent: a second commit with the same text is a no-op + // (commit early-returns on next === lastHtml). + commitAndFlip(); + }); + + textarea.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + commitAndFlip(); + } else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + commitAndFlip(); + } + }); + + // `mousedown.preventDefault` keeps focus on the textarea so the + // subsequent click handler runs in the same selection context; + // without this, the button steals focus → blur fires first → + // commitAndFlip → click fires on a hidden element → no-op. + doneBtn.addEventListener('mousedown', (e) => e.preventDefault()); + doneBtn.addEventListener('click', (e) => { + e.preventDefault(); + commitAndFlip(); + }); return { dom: wrapper, + // Tell ProseMirror to ignore events that originated inside + // our source pane — without this, typing common HTML like + // `` bubbles `/` up to the slash-menu plugin in the + // surrounding editor, and Enter / arrow keys can be eaten + // by ProseMirror commands instead of editing the textarea. + // Preview-pane events still flow through normally so links / + // iframes / embedded controls behave as users expect. + stopEvent(event: Event) { + // `Node` here would otherwise resolve to the TipTap `Node` + // class (imported at the top); use `globalThis.Node` to + // reach the DOM Node interface that source.contains expects. + const target = event.target as globalThis.Node | null; + return target !== null && source.contains(target); + }, update(updatedNode: ProseMirrorNode) { if (updatedNode.type.name !== 'htmlBlock') return false; const next = (updatedNode.attrs.html as string | undefined) ?? ''; if (next !== lastHtml) { lastHtml = next; - wrapper.innerHTML = sanitizeHtmlBlock(next); + // Only re-render the preview pane. Don't touch the + // textarea — the user might be mid-edit. They'll see + // fresh content on the next flipToSource call. + renderPreview(); } return true; }, - // Mutations inside our sanitized innerHTML are render-only — - // we own the wrapper. Skip ProseMirror's MutationObserver - // to avoid re-parse loops (mirrors the MermaidCodeBlock - // pattern in Editor.svelte). + // Mutations inside our sanitized innerHTML / textarea are + // render-only — we own the wrapper. Skip ProseMirror's + // MutationObserver to avoid re-parse loops (mirrors the + // MermaidCodeBlock pattern in Editor.svelte). ignoreMutation() { return true; },