From 7e8812a752ec68b0481eac0f672c732c164abf65 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 7 Oct 2026 11:41:56 -0700 Subject: [PATCH] feat(site-builder): inline bold/italic/underline/links on Heading and Text Select text inside a selected Heading or Text block and a floating toolbar offers B / I / U / Link / Unlink (execCommand; mousedown is prevented so the selection survives; mod+b/i/u handled on the editable). Storage: new `html` prop holding sanitised inline HTML, with `text` kept as the plain mirror for AI summaries, search and legacy content. Unmarked edits store html '' so legacy nodes keep their shape. The allowlist (b strong i em u a br span; style=color only, hex/rgb/ var(--wsb-*); href via DOMPurify URI check AND safeUrl, unsafe links unwrapped; target only _blank with forced rel=noopener) runs on commit, on canvas render and again at export. If `text` was changed without `html` (Sitesmith update_props), the stale html is ignored. Each edit commits once (blur, or deselect if no blur came first) as one setProp = one undo step; the old blur+deselect double commit is gone. Heading colour default now tokenRef('text'). Sitesmith canvas summary skips `html`. Refs #46 Co-Authored-By: Claude Opus 5.5 --- .../components/basic/Heading.toHtml.test.ts | 47 +++++ craft/src/components/basic/Heading.tsx | 52 ++--- .../components/basic/TextBlock.toHtml.test.ts | 43 ++++ craft/src/components/basic/TextBlock.tsx | 41 ++-- .../components/basic/inline-editing.test.tsx | 170 ++++++++++++++++ .../src/components/basic/useInlineEditable.ts | 75 +++++++ craft/src/editor/CanvasOverlays.tsx | 2 + craft/src/editor/RichTextToolbar.test.tsx | 174 +++++++++++++++++ craft/src/editor/RichTextToolbar.tsx | 184 ++++++++++++++++++ craft/src/styles/editor.css | 61 ++++++ craft/src/utils/canvas-summary.test.ts | 18 ++ craft/src/utils/canvas-summary.ts | 3 +- craft/src/utils/inline-html.test.ts | 118 +++++++++++ craft/src/utils/inline-html.ts | 171 ++++++++++++++++ 14 files changed, 1091 insertions(+), 68 deletions(-) create mode 100644 craft/src/components/basic/inline-editing.test.tsx create mode 100644 craft/src/components/basic/useInlineEditable.ts create mode 100644 craft/src/editor/RichTextToolbar.test.tsx create mode 100644 craft/src/editor/RichTextToolbar.tsx create mode 100644 craft/src/utils/inline-html.test.ts create mode 100644 craft/src/utils/inline-html.ts diff --git a/craft/src/components/basic/Heading.toHtml.test.ts b/craft/src/components/basic/Heading.toHtml.test.ts index 69dc9ab..1236510 100644 --- a/craft/src/components/basic/Heading.toHtml.test.ts +++ b/craft/src/components/basic/Heading.toHtml.test.ts @@ -107,3 +107,50 @@ describe('Heading.craft.props exposes the box-model + animation/visibility rollo expect(style.opacity).toBe('1'); }); }); + +describe('Heading.toHtml inline rich text (#46)', () => { + test('legacy plain text with html="" is escaped', () => { + expect(toHtml({ level: 'h2', text: 'aa<b'); + }); + + test('marked html is emitted (html-only props)', () => { + expect(toHtml({ level: 'h2', html: 'Hi there' }, '').html).toBe('

Hi there

'); + }); + + test('html whose text mirror matches text is emitted', () => { + expect(toHtml({ level: 'h2', text: 'Hi there', html: 'Hi there' }, '').html).toBe('

Hi there

'); + }); + + test('text changed without html (e.g. AI update_props) wins over stale html', () => { + expect(toHtml({ level: 'h2', text: 'New ', html: 'Old copy' }, '').html).toBe('

New <copy>

'); + }); + + test.each([ + ['', 'alert(1)', 'x', 'javascript:'], + ['x', 'href'], + ['x', 'onclick'], + ['x', 'position'], + ['

', 'iframe'], + ['', 'onload'], + ])('malicious stored html is sanitised at export: %s', (html, banned) => { + const out = toHtml({ level: 'h2', html }, '').html as string; + expect(out.toLowerCase()).not.toContain(banned); + expect(out.startsWith('')).toBe(true); + }); + + test('external _blank link gets rel=noopener at export', () => { + expect(toHtml({ level: 'h2', html: 'x' }, '').html) + .toBe('

x

'); + }); + + test('craft.props carries a blank html default', () => { + expect((Heading as any).craft.props.html).toBe(''); + }); +}); + +test('Heading default colour is the text token', () => { + expect((Heading as any).craft.props.style.color).toBe('var(--wsb-text)'); +}); diff --git a/craft/src/components/basic/Heading.tsx b/craft/src/components/basic/Heading.tsx index e1abcf9..dab636f 100644 --- a/craft/src/components/basic/Heading.tsx +++ b/craft/src/components/basic/Heading.tsx @@ -1,7 +1,10 @@ -import React, { CSSProperties, useCallback, useRef, useEffect } from 'react'; +import React, { CSSProperties } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { escapeHtml } from '../../utils/escape'; +import { resolveInlineHtml } from '../../utils/inline-html'; +import { tokenRef } from '../../utils/tokens'; +import { useInlineEditable } from './useInlineEditable'; type HeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; @@ -17,6 +20,8 @@ const sanitizeHeadingLevel = (level: unknown): HeadingLevel => interface HeadingProps { text?: string; + /** Sanitised inline rich text (#46); '' = plain `text` only. */ + html?: string; level?: HeadingLevel; style?: CSSProperties; cssId?: string; @@ -30,6 +35,7 @@ interface HeadingProps { export const Heading: UserComponent = ({ text = 'Heading', + html = '', level = 'h2', style = {}, }) => { @@ -42,34 +48,7 @@ export const Heading: UserComponent = ({ })); const safeLevel = sanitizeHeadingLevel(level); - const elRef = useRef(null); - const editedTextRef = useRef(null); - - const commitText = useCallback(() => { - if (elRef.current) { - const newText = elRef.current.innerText; - editedTextRef.current = newText; - setProp((p: HeadingProps) => { p.text = newText; }); - } - }, [setProp]); - - // Commit on blur - const handleBlur = useCallback(() => { commitText(); }, [commitText]); - - // Also commit on deselect via effect - useEffect(() => { - if (!selected && editedTextRef.current !== null) { - setProp((p: HeadingProps) => { p.text = editedTextRef.current!; }); - editedTextRef.current = null; - } - }, [selected, setProp]); - - // Set DOM text on mount and when text prop changes externally (not during editing) - useEffect(() => { - if (elRef.current && !selected && editedTextRef.current === null) { - elRef.current.innerText = text || ''; - } - }, [text, selected]); + const { elRef, onBlur, onInput, onKeyDown } = useInlineEditable(text, html, selected, setProp); return React.createElement(safeLevel, { ref: (ref: HTMLElement | null): void => { @@ -78,13 +57,9 @@ export const Heading: UserComponent = ({ }, contentEditable: selected, suppressContentEditableWarning: true, - onBlur: handleBlur, - onInput: () => { - // Track that we have unsaved edits - if (elRef.current) { - editedTextRef.current = elRef.current.innerText; - } - }, + onBlur, + onInput, + onKeyDown, style: { outline: 'none', cursor: selected ? 'text' : 'pointer', minHeight: '1em', ...style }, }); }; @@ -93,12 +68,13 @@ Heading.craft = { displayName: 'Heading', props: { text: 'Your Heading', + html: '', level: 'h2' as HeadingLevel, style: { fontSize: '36px', fontWeight: '700', fontFamily: 'Inter, sans-serif', - color: '#1f2937', + color: tokenRef('text'), marginBottom: '16px', lineHeight: '', letterSpacing: '', @@ -126,7 +102,7 @@ Heading.craft = { (Heading as any).toHtml = (props: HeadingProps, _childrenHtml: string) => { const tag = sanitizeHeadingLevel(props.level); - const safeText = escapeHtml(props.text || ''); + const safeText = resolveInlineHtml(props.text, props.html) ?? escapeHtml(props.text || ''); const styleStr = cssPropsToString(props.style); return { html: `<${tag}${styleStr ? ` style="${styleStr}"` : ''}>${safeText}` }; }; diff --git a/craft/src/components/basic/TextBlock.toHtml.test.ts b/craft/src/components/basic/TextBlock.toHtml.test.ts index 483a653..43ad5f2 100644 --- a/craft/src/components/basic/TextBlock.toHtml.test.ts +++ b/craft/src/components/basic/TextBlock.toHtml.test.ts @@ -65,3 +65,46 @@ describe('TextBlock.craft.props exposes the box-model + animation/visibility rol expect(style.opacity).toBe('1'); }); }); + +describe('TextBlock.toHtml inline rich text (#46)', () => { + test('legacy plain text with html="" is escaped', () => { + expect(toHtml({ text: 'aa<b

'); + }); + + test('marked html is emitted (html-only props)', () => { + expect(toHtml({ html: 'Hi there' }, '').html).toBe('

Hi there

'); + }); + + test('html whose text mirror matches text is emitted', () => { + expect(toHtml({ text: 'Hi there', html: 'Hi there' }, '').html).toBe('

Hi there

'); + }); + + test('text changed without html (e.g. AI update_props) wins over stale html', () => { + expect(toHtml({ text: 'New ', html: 'Old copy' }, '').html).toBe('

New <copy>

'); + }); + + test.each([ + ['', 'alert(1)', 'x', 'javascript:'], + ['x', 'href'], + ['x', 'onclick'], + ['x', 'position'], + ['

', 'iframe'], + ['', 'onload'], + ])('malicious stored html is sanitised at export: %s', (html, banned) => { + const out = toHtml({ html }, '').html as string; + expect(out.toLowerCase()).not.toContain(banned); + expect(out.startsWith('')).toBe(true); + }); + + test('external _blank link gets rel=noopener at export', () => { + expect(toHtml({ html: 'x' }, '').html) + .toBe('

x

'); + }); + + test('craft.props carries a blank html default', () => { + expect((TextBlock as any).craft.props.html).toBe(''); + }); +}); diff --git a/craft/src/components/basic/TextBlock.tsx b/craft/src/components/basic/TextBlock.tsx index 3a855d3..5bb21b2 100644 --- a/craft/src/components/basic/TextBlock.tsx +++ b/craft/src/components/basic/TextBlock.tsx @@ -1,10 +1,14 @@ -import React, { CSSProperties, useCallback, useRef, useEffect } from 'react'; +import React, { CSSProperties } from 'react'; import { useNode, UserComponent } from '@craftjs/core'; import { cssPropsToString } from '../../utils/style-helpers'; import { escapeHtml } from '../../utils/escape'; +import { resolveInlineHtml } from '../../utils/inline-html'; +import { useInlineEditable } from './useInlineEditable'; interface TextBlockProps { text?: string; + /** Sanitised inline rich text (#46); '' = plain `text` only. */ + html?: string; style?: CSSProperties; cssId?: string; cssClass?: string; @@ -17,6 +21,7 @@ interface TextBlockProps { export const TextBlock: UserComponent = ({ text = 'Start typing here...', + html = '', style = {}, }) => { const { @@ -27,31 +32,7 @@ export const TextBlock: UserComponent = ({ selected: node.events.selected, })); - const elRef = useRef(null); - const editedTextRef = useRef(null); - - const commitText = useCallback(() => { - if (elRef.current) { - const newText = elRef.current.innerText; - editedTextRef.current = newText; - setProp((p: TextBlockProps) => { p.text = newText; }); - } - }, [setProp]); - - const handleBlur = useCallback(() => { commitText(); }, [commitText]); - - useEffect(() => { - if (!selected && editedTextRef.current !== null) { - setProp((p: TextBlockProps) => { p.text = editedTextRef.current!; }); - editedTextRef.current = null; - } - }, [selected, setProp]); - - useEffect(() => { - if (elRef.current && !selected && editedTextRef.current === null) { - elRef.current.innerText = text || ''; - } - }, [text, selected]); + const { elRef, onBlur, onInput, onKeyDown } = useInlineEditable(text, html, selected, setProp); return (

= ({ }} contentEditable={selected} suppressContentEditableWarning - onBlur={handleBlur} - onInput={() => { if (elRef.current) editedTextRef.current = elRef.current.innerText; }} + onBlur={onBlur} + onInput={onInput} + onKeyDown={onKeyDown} style={{ outline: 'none', cursor: selected ? 'text' : 'pointer', @@ -79,6 +61,7 @@ TextBlock.craft = { displayName: 'Text', props: { text: 'Start typing here...', + html: '', style: { fontSize: '16px', lineHeight: '1.6', @@ -110,6 +93,6 @@ TextBlock.craft = { (TextBlock as any).toHtml = (props: TextBlockProps, _childrenHtml: string) => { const styleStr = cssPropsToString(props.style); - const escapedText = escapeHtml(props.text || ''); + const escapedText = resolveInlineHtml(props.text, props.html) ?? escapeHtml(props.text || ''); return { html: `${escapedText}

` }; }; diff --git a/craft/src/components/basic/inline-editing.test.tsx b/craft/src/components/basic/inline-editing.test.tsx new file mode 100644 index 0000000..e0086ab --- /dev/null +++ b/craft/src/components/basic/inline-editing.test.tsx @@ -0,0 +1,170 @@ +import { describe, test, expect, afterEach, vi } from 'vitest'; +import { renderEditorHarness, EditorHarness } from '../../test-utils/editorHarness'; + +/** + * #46 inline rich text against a REAL Craft.js editor: contentEditable + * commits store sanitised `html` + plain `text` as ONE undo step, a deselect + * after blur adds no second step, and load (deserialize + history.clear) + * leaves nothing to undo. + */ +function stateWith(nodeType: 'Heading' | 'TextBlock', props: Record) { + return JSON.stringify({ + ROOT: { + type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' }, + displayName: 'Container', custom: {}, hidden: false, nodes: ['n-1'], linkedNodes: {}, + }, + 'n-1': { + type: { resolvedName: nodeType }, isCanvas: false, + props: nodeType === 'Heading' ? { level: 'h2', style: {}, ...props } : { style: {}, ...props }, + displayName: nodeType === 'Heading' ? 'Heading' : 'Text', custom: {}, hidden: false, + parent: 'ROOT', nodes: [], linkedNodes: {}, + }, + }); +} + +let harness: EditorHarness | null = null; +afterEach(() => { + vi.restoreAllMocks(); + delete (document as any).execCommand; + harness?.unmount(); + harness = null; +}); + +function mount(nodeType: 'Heading' | 'TextBlock', props: Record) { + harness = renderEditorHarness({ initialState: stateWith(nodeType, props) }); + harness.act(() => { harness!.actions.history.clear(); }); + return harness; +} + +const el = () => harness!.query.node('n-1').get().dom as HTMLElement; +const nodeProps = () => JSON.parse(harness!.getSerialized())['n-1'].props; +const select = () => harness!.act(() => { harness!.actions.selectNode('n-1'); }); +const deselect = () => harness!.act(() => { harness!.actions.selectNode(); }); +const typeHtml = (html: string) => harness!.act(() => { + el().innerHTML = html; + el().dispatchEvent(new Event('input', { bubbles: true })); +}); +const blur = () => harness!.act(() => { el().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); }); + +function undoDepth(): number { + let n = 0; + while (harness!.query.history.canUndo() && n < 50) { + harness!.act(() => { harness!.actions.history.undo(); }); + n++; + } + return n; +} + +describe.each(['Heading', 'TextBlock'] as const)('%s inline editing (#46)', (type) => { + test('a marked edit stores sanitised html + plain text mirror', () => { + mount(type, { text: 'Hello', html: '' }); + select(); + expect(el().getAttribute('contenteditable')).toBe('true'); + typeHtml('Hello World'); + blur(); + expect(nodeProps().html).toBe('Hello World'); + expect(nodeProps().text).toBe('Hello World'); + }); + + test('blur then deselect is ONE undo step (no double commit)', () => { + mount(type, { text: 'Hello', html: '' }); + select(); + typeHtml('Hello World'); + blur(); + deselect(); + expect(undoDepth()).toBe(1); + expect(nodeProps().text).toBe('Hello'); + expect(nodeProps().html).toBe(''); + // Undo of a deselected node re-renders the DOM from props. + expect(el().innerHTML).toBe('Hello'); + }); + + test('deselect without blur commits exactly once', () => { + mount(type, { text: 'Hello', html: '' }); + select(); + typeHtml('Hi there'); + deselect(); + expect(nodeProps().html).toBe('Hi there'); + expect(undoDepth()).toBe(1); + }); + + test('a plain edit leaves html empty (legacy shape)', () => { + mount(type, { text: 'Hello', html: '' }); + select(); + typeHtml('Fish & chips'); + blur(); + expect(nodeProps().html).toBe(''); + expect(nodeProps().text).toBe('Fish & chips'); + }); + + test('Enter-created block lines survive as newlines in text', () => { + mount(type, { text: 'a', html: '' }); + select(); + typeHtml('a
b
'); + blur(); + expect(nodeProps().text).toBe('a\nb'); + expect(nodeProps().html).toBe(''); + }); + + test('hostile markup typed/pasted into the editable is sanitised on commit', () => { + mount(type, { text: 'x', html: '' }); + select(); + typeHtml('click ok'); + blur(); + expect(nodeProps().html).toBe('click ok'); + expect(nodeProps().text).toBe('click ok'); + }); + + test('external links created by createLink get target=_blank + rel=noopener on commit', () => { + mount(type, { text: 'x', html: '' }); + select(); + typeHtml('x'); + blur(); + expect(nodeProps().html).toBe('x'); + }); + + test('blur with no edit commits nothing', () => { + mount(type, { text: 'Hello', html: '' }); + select(); + blur(); + deselect(); + expect(harness!.query.history.canUndo()).toBe(false); + }); + + test('load (deserialize + history.clear) renders stored html and leaves nothing to undo', () => { + mount(type, { text: 'Hi there', html: 'Hi there' }); + expect(el().innerHTML).toBe('Hi there'); + harness!.act(() => { + harness!.actions.deserialize(stateWith(type, { text: 'Page two', html: 'Page two' })); + }); + harness!.act(() => { harness!.actions.history.clear(); }); + expect(el().innerHTML).toBe('Page two'); + expect(harness!.query.history.canUndo()).toBe(false); + }); + + test('hostile stored html never reaches the canvas DOM', () => { + mount(type, { text: '', html: 'safe' }); + expect(el().innerHTML).toBe('safe'); + }); + + test('stale html is ignored when text was changed without it (AI update_props)', () => { + mount(type, { text: 'Hi there', html: 'Hi there' }); + harness!.act(() => { harness!.actions.setProp('n-1', (p: any) => { p.text = 'Brand '; }); }); + expect(el().innerHTML).toBe('Brand <new>'); + }); + + test('mod+b while typing runs execCommand(bold) and marks the edit pending', () => { + mount(type, { text: 'Hello', html: '' }); + select(); + // jsdom has no execCommand at all -- install a stub for this test. + const exec = vi.fn(() => { el().innerHTML = 'Hello'; return true; }); + (document as any).execCommand = exec; + harness!.act(() => { + el().dispatchEvent(new KeyboardEvent('keydown', { key: 'b', ctrlKey: true, bubbles: true })); + }); + expect(exec).toHaveBeenCalledWith('bold'); + blur(); + expect(nodeProps().html).toBe('Hello'); + expect(undoDepth()).toBe(1); + }); +}); diff --git a/craft/src/components/basic/useInlineEditable.ts b/craft/src/components/basic/useInlineEditable.ts new file mode 100644 index 0000000..7d4c22a --- /dev/null +++ b/craft/src/components/basic/useInlineEditable.ts @@ -0,0 +1,75 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { + sanitizeInlineHtml, + inlineHtmlToText, + isPlainInlineHtml, + textToInlineHtml, + resolveInlineHtml, + markExternalLinks, +} from '../../utils/inline-html'; + +interface InlineTextProps { + text?: string; + html?: string; +} + +const SHORTCUTS: Record = { b: 'bold', i: 'italic', u: 'underline' }; + +/** + * contentEditable wiring shared by Heading and TextBlock (#46). + * + * - The DOM is written imperatively (innerHTML) only while the node is NOT + * selected and has no pending edit, so the caret is never reset mid-typing. + * - An edit is committed ONCE -- on blur, or on deselect if no blur came + * first -- as a single `setProp` (= one undo step) that stores BOTH the + * sanitised `html` and its plain `text` mirror. Unmarked content stores + * `html: ''` so legacy nodes keep their legacy shape. + * - mod+b/i/u is handled locally: the global shortcut dispatcher skips + * contentEditable targets. + */ +export function useInlineEditable( + text: string, + html: string, + selected: boolean, + setProp: (cb: (p: InlineTextProps) => void) => void, +) { + const elRef = useRef(null); + const pendingRef = useRef(false); + + const commit = useCallback(() => { + if (!pendingRef.current) return; + pendingRef.current = false; + const el = elRef.current; + if (!el) return; + markExternalLinks(el); + const clean = sanitizeInlineHtml(el.innerHTML); + const nextText = inlineHtmlToText(clean); + const nextHtml = isPlainInlineHtml(clean) ? '' : clean; + setProp((p) => { p.html = nextHtml; p.text = nextText; }); + }, [setProp]); + + // Deselect without a blur (e.g. Escape, programmatic selection change). + useEffect(() => { + if (!selected) commit(); + }, [selected, commit]); + + useEffect(() => { + if (elRef.current && !selected && !pendingRef.current) { + elRef.current.innerHTML = resolveInlineHtml(text, html) ?? textToInlineHtml(text || ''); + } + }, [text, html, selected]); + + const onInput = useCallback(() => { pendingRef.current = true; }, []); + + const onKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!(e.metaKey || e.ctrlKey) || e.shiftKey || e.altKey) return; + const cmd = SHORTCUTS[e.key.toLowerCase()]; + if (!cmd) return; + if (typeof document.execCommand !== 'function') return; + e.preventDefault(); + document.execCommand(cmd); + pendingRef.current = true; + }, []); + + return { elRef, onBlur: commit, onInput, onKeyDown }; +} diff --git a/craft/src/editor/CanvasOverlays.tsx b/craft/src/editor/CanvasOverlays.tsx index eb99ed7..41afc69 100644 --- a/craft/src/editor/CanvasOverlays.tsx +++ b/craft/src/editor/CanvasOverlays.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { NoticeHost } from './NoticeHost'; +import { RichTextToolbar } from './RichTextToolbar'; /** * Single mount point for editor-wide overlays rendered next to the shell. @@ -9,5 +10,6 @@ import { NoticeHost } from './NoticeHost'; export const CanvasOverlays: React.FC = () => ( <> + ); diff --git a/craft/src/editor/RichTextToolbar.test.tsx b/craft/src/editor/RichTextToolbar.test.tsx new file mode 100644 index 0000000..dc31e62 --- /dev/null +++ b/craft/src/editor/RichTextToolbar.test.tsx @@ -0,0 +1,174 @@ +import { describe, test, expect, afterEach, vi } from 'vitest'; +import React from 'react'; +import { renderEditorHarness, EditorHarness } from '../test-utils/editorHarness'; +import { RichTextToolbar, normalizeLinkInput } from './RichTextToolbar'; + +const STATE = JSON.stringify({ + ROOT: { + type: { resolvedName: 'Container' }, isCanvas: true, props: { style: {}, tag: 'div' }, + displayName: 'Container', custom: {}, hidden: false, nodes: ['h-1', 'b-1'], linkedNodes: {}, + }, + 'h-1': { + type: { resolvedName: 'Heading' }, isCanvas: false, + props: { text: 'Hello World', html: '', level: 'h2', style: {} }, + displayName: 'Heading', custom: {}, hidden: false, parent: 'ROOT', nodes: [], linkedNodes: {}, + }, + 'b-1': { + type: { resolvedName: 'ButtonLink' }, isCanvas: false, + props: { text: 'Go', href: '#' }, + displayName: 'Button', custom: {}, hidden: false, parent: 'ROOT', nodes: [], linkedNodes: {}, + }, +}); + +let harness: EditorHarness | null = null; +let exec: ReturnType; + +afterEach(() => { + window.getSelection()?.removeAllRanges(); + delete (document as any).execCommand; + harness?.unmount(); + harness = null; +}); + +function setup() { + harness = renderEditorHarness({ initialState: STATE }); + harness.mountChild(); + harness.act(() => { harness!.actions.history.clear(); }); + // jsdom has no execCommand; stub one that records calls. + exec = vi.fn(() => true); + (document as any).execCommand = exec; +} + +const heading = () => harness!.query.node('h-1').get().dom as HTMLElement; +const toolbar = () => harness!.container.querySelector('.rt-toolbar'); +const btn = (label: string) => harness!.container.querySelector(`[aria-label="${label}"]`) as HTMLElement; + +function selectText(el: HTMLElement) { + harness!.act(() => { + const range = document.createRange(); + range.selectNodeContents(el); + const sel = window.getSelection()!; + sel.removeAllRanges(); + sel.addRange(range); + document.dispatchEvent(new Event('selectionchange')); + }); +} + +function press(el: HTMLElement) { + harness!.act(() => { + const down = new MouseEvent('mousedown', { bubbles: true, cancelable: true }); + el.dispatchEvent(down); + expect(down.defaultPrevented).toBe(true); // selection must survive + el.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); +} + +describe('RichTextToolbar (#46)', () => { + test('renders over a non-collapsed selection inside a selected Heading', () => { + setup(); + harness!.act(() => { harness!.actions.selectNode('h-1'); }); + expect(toolbar()).toBeNull(); + selectText(heading()); + expect(toolbar()).not.toBeNull(); + expect(toolbar()!.getAttribute('role')).toBe('toolbar'); + }); + + test('B / I / U / Unlink run the matching execCommand and mark the edit', () => { + setup(); + harness!.act(() => { harness!.actions.selectNode('h-1'); }); + selectText(heading()); + press(btn('Bold')); + press(btn('Italic')); + press(btn('Underline')); + press(btn('Remove link')); + expect(exec.mock.calls.map((c) => c[0])).toEqual(['bold', 'italic', 'underline', 'unlink']); + }); + + test('B followed by blur commits the formatted html as one undo step', () => { + setup(); + harness!.act(() => { harness!.actions.selectNode('h-1'); }); + selectText(heading()); + exec.mockImplementation(() => { heading().innerHTML = 'Hello World'; return true; }); + press(btn('Bold')); + harness!.act(() => { heading().dispatchEvent(new FocusEvent('focusout', { bubbles: true })); }); + const props = harness!.query.node('h-1').get().data.props; + expect(props.html).toBe('Hello World'); + expect(props.text).toBe('Hello World'); + expect(harness!.query.history.canUndo()).toBe(true); + harness!.act(() => { harness!.actions.history.undo(); }); + expect(harness!.query.history.canUndo()).toBe(false); + }); + + test('hidden when the selection collapses', () => { + setup(); + harness!.act(() => { harness!.actions.selectNode('h-1'); }); + selectText(heading()); + expect(toolbar()).not.toBeNull(); + harness!.act(() => { + window.getSelection()!.collapseToStart(); + document.dispatchEvent(new Event('selectionchange')); + }); + expect(toolbar()).toBeNull(); + }); + + test('hidden when the node is deselected', () => { + setup(); + harness!.act(() => { harness!.actions.selectNode('h-1'); }); + selectText(heading()); + harness!.act(() => { harness!.actions.selectNode(); }); + expect(toolbar()).toBeNull(); + }); + + test('not shown for a selection in an unselected Heading', () => { + setup(); + selectText(heading()); + expect(toolbar()).toBeNull(); + }); + + test('not shown for a contenteditable that is not a selected Heading/Text node', () => { + setup(); + harness!.act(() => { harness!.actions.selectNode('b-1'); }); + const stray = document.createElement('div'); + stray.setAttribute('contenteditable', 'true'); + stray.textContent = 'elsewhere'; + document.body.appendChild(stray); + selectText(stray); + expect(toolbar()).toBeNull(); + stray.remove(); + }); + + test('Link: unsafe URL is refused, a bare domain becomes https://', () => { + setup(); + harness!.act(() => { harness!.actions.selectNode('h-1'); }); + selectText(heading()); + press(btn('Link')); + const input = harness!.container.querySelector('input[aria-label="Link URL"]') as HTMLInputElement; + expect(input).not.toBeNull(); + const submit = (v: string) => harness!.act(() => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, v); + input.dispatchEvent(new Event('input', { bubbles: true })); + input.form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true })); + }); + submit('javascript:alert(1)'); + expect(exec).not.toHaveBeenCalled(); + expect(input.getAttribute('aria-invalid')).toBe('true'); + submit('example.com/path'); + expect(exec).toHaveBeenCalledWith('createLink', false, 'https://example.com/path'); + }); +}); + +describe('normalizeLinkInput', () => { + test.each([ + ['example.com', 'https://example.com'], + ['https://e.com/x', 'https://e.com/x'], + ['/about', '/about'], + ['#contact', '#contact'], + ['mailto:a@b.co', 'mailto:a@b.co'], + ['javascript:alert(1)', ''], + [' JaVa\tScRiPt:alert(1)', ''], + ['data:text/html,x', ''], + ['', ''], + ])('%j -> %j', (input, expected) => { + expect(normalizeLinkInput(input)).toBe(expected); + }); +}); diff --git a/craft/src/editor/RichTextToolbar.tsx b/craft/src/editor/RichTextToolbar.tsx new file mode 100644 index 0000000..2c15e16 --- /dev/null +++ b/craft/src/editor/RichTextToolbar.tsx @@ -0,0 +1,184 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useEditor } from '@craftjs/core'; +import { safeUrl } from '../utils/escape'; +import { markExternalLinks } from '../utils/inline-html'; + +/** Craft displayNames whose editable stores inline rich text (#46). */ +const RICH_TEXT_TYPES = new Set(['Heading', 'Text']); +const TOOLBAR_GAP = 8; + +/** `example.com` -> `https://example.com`; leaves relative/anchor/mailto/tel and schemed URLs alone. */ +export function normalizeLinkInput(raw: string): string { + const v = raw.trim(); + if (!v) return ''; + if (/^[a-z][a-z0-9+.-]*:/i.test(v) || /^[/#?.]/.test(v)) return safeUrl(v); + if (/^[^\s/]+\.[^\s/]+/.test(v)) return safeUrl(`https://${v}`); + return safeUrl(v); +} + +function runCommand(command: string, value?: string): void { + if (typeof document.execCommand === 'function') document.execCommand(command, false, value); +} + +interface Placement { top: number; left: number } + +/** + * Floating B / I / U / Link / Unlink bar over a non-collapsed selection inside + * the editable of a selected Heading or Text node. Formatting is plain + * `document.execCommand`; the component's own commit (blur/deselect) + * sanitises and stores the result, so this bar never writes props itself. + * Buttons `preventDefault` on mousedown so the text selection survives. + */ +export const RichTextToolbar: React.FC = () => { + const { query, selectedKey } = useEditor((state) => ({ + selectedKey: Array.from(state.events.selected).join(','), + })); + + const [placement, setPlacement] = useState(null); + const [linkOpen, setLinkOpen] = useState(false); + const [linkValue, setLinkValue] = useState(''); + const [linkError, setLinkError] = useState(false); + const editableRef = useRef(null); + const savedRangeRef = useRef(null); + const linkOpenRef = useRef(false); + linkOpenRef.current = linkOpen; + + /** The selected rich-text node's editable that contains `range`, if any. */ + const editableFor = useCallback((range: Range): HTMLElement | null => { + const start = range.commonAncestorContainer; + const host = (start.nodeType === Node.ELEMENT_NODE ? start as Element : start.parentElement) + ?.closest('[contenteditable="true"]') as HTMLElement | null; + if (!host) return null; + let ids: string[] = []; + try { ids = query.getEvent('selected').all(); } catch { return null; } + for (const id of ids) { + const node = query.node(id).get(); + if (node?.dom === host && RICH_TEXT_TYPES.has(node.data.displayName)) return host; + } + return null; + }, [query]); + + const update = useCallback(() => { + if (linkOpenRef.current) return; + const sel = typeof window.getSelection === 'function' ? window.getSelection() : null; + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { setPlacement(null); return; } + const range = sel.getRangeAt(0); + const host = editableFor(range); + if (!host) { setPlacement(null); return; } + editableRef.current = host; + const rect = typeof range.getBoundingClientRect === 'function' + ? range.getBoundingClientRect() + : host.getBoundingClientRect(); + setPlacement({ top: Math.max(rect.top - TOOLBAR_GAP, 0), left: rect.left + rect.width / 2 }); + }, [editableFor]); + + useEffect(() => { + document.addEventListener('selectionchange', update); + return () => document.removeEventListener('selectionchange', update); + }, [update]); + + // Node selection changed (deselect, another node): re-evaluate. + useEffect(() => { + setLinkOpen(false); + linkOpenRef.current = false; + update(); + }, [selectedKey, update]); + + /** Tell the component an edit happened (real browsers also fire `input`). */ + const markEdited = () => { + editableRef.current?.dispatchEvent(new Event('input', { bubbles: true })); + }; + + const run = (command: string) => { + runCommand(command); + markEdited(); + update(); + }; + + const restoreSelection = (): boolean => { + const host = editableRef.current; + const range = savedRangeRef.current; + const sel = window.getSelection?.(); + if (!host || !range || !sel) return false; + host.focus(); + sel.removeAllRanges(); + sel.addRange(range); + return true; + }; + + const openLink = () => { + const sel = window.getSelection?.(); + if (!sel || sel.rangeCount === 0) return; + savedRangeRef.current = sel.getRangeAt(0).cloneRange(); + const anchor = (sel.anchorNode?.parentElement)?.closest('a'); + setLinkValue(anchor?.getAttribute('href') || ''); + setLinkError(false); + setLinkOpen(true); + }; + + const closeLink = (restore: boolean) => { + setLinkOpen(false); + linkOpenRef.current = false; + if (restore) restoreSelection(); + savedRangeRef.current = null; + update(); + }; + + const applyLink = () => { + const url = normalizeLinkInput(linkValue); + if (!url) { setLinkError(true); return; } + if (!restoreSelection()) { closeLink(false); return; } + runCommand('createLink', url); + if (editableRef.current) markExternalLinks(editableRef.current); + markEdited(); + closeLink(false); + }; + + if (!placement) return null; + + const keep = (e: React.MouseEvent) => e.preventDefault(); + const button = (label: string, icon: string, onClick: () => void) => ( +