mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 18:13:26 +00:00
feat(editor): source-view toggle for htmlBlock nodes (TASK-1325) (#475)
* feat(editor): source-view toggle for htmlBlock nodes (TASK-1325) Extends the htmlBlock NodeView with a click-to-edit source pane. The block renders sanitized live HTML by default; clicking the preview flips to a raw HTML textarea bound to attrs.html. Blur, Escape, or Cmd/Ctrl+Enter commits via setNodeMarkup and flips back to preview. Behavior: - Click anywhere in the rendered preview → flip to source mode and focus the textarea with caret at end. Clicks on interactive descendants (a, button, iframe, input, textarea, select, video, audio) pass through normally so embedded controls stay clickable. - Escape: preventDefault + commitAndFlip. Per task spec, Escape commits rather than cancelling — matches the project's existing block UX where edits aren't undone by escape. - Cmd/Ctrl+Enter: same as Escape — one-shot commit-and-flip. - Blur: also commits. The handler is idempotent (commit early-returns when textarea.value === lastHtml) so the Done-button click path doesn't double-commit when blur fires after the click. - Done button: mousedown.preventDefault keeps focus on the textarea so the click handler runs in the same selection context. Without that, the button would steal focus → blur → commitAndFlip → click on a hidden element no-op. - Empty block: shows "Empty HTML block — click to edit" placeholder so the atom node remains discoverable when attrs.html is empty. NodeView's update() handler re-renders only the preview when external attrs.html changes (e.g. via collab transactions). The textarea isn't auto-synced — if the user is mid-edit when a remote change lands, their in-progress text wins on the next commit. Last-write-wins is fine for v1; collab-aware merge would be its own task. CSS lives in Editor.svelte's <style> block immediately after the mermaid-source rule, using the same .editor-content :global(...) pattern as every other block-level element. The wrapper toggles between preview and source via the .html-block--editing class. Out of scope (separate tasks): - TASK-1326 — slash menu / toolbar / markdown shortcut to insert - TASK-1327 — hidden-content authoring warning - TASK-1328 — diff view collapse Parent: PLAN-1322. * fix(editor): isolate htmlBlock textarea events + gate edit on isEditable per Codex review (round 1)
This commit is contained in:
@@ -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 */
|
||||
|
||||
@@ -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 =
|
||||
'<span class="html-block-empty">Empty HTML block — click to edit</span>';
|
||||
} 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
|
||||
// `</div>` 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;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user