Merge issue-46 (Refs #44 #46)

# Conflicts:
#	craft/src/editor/CanvasOverlays.tsx
#	craft/src/styles/editor.css
This commit is contained in:
shadowdao committed 2026-10-07 11:47:54 -07:00
commit 98e32a68b9
14 files changed
+1091 -68

No files matched your search

@@ -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: 'a<b', html: '' }, '').html).toBe('<h2>a&lt;b</h2>');
});
test('marked html is emitted (html-only props)', () => {
expect(toHtml({ level: 'h2', html: '<b>Hi</b> there' }, '').html).toBe('<h2><b>Hi</b> there</h2>');
});
test('html whose text mirror matches text is emitted', () => {
expect(toHtml({ level: 'h2', text: 'Hi there', html: '<b>Hi</b> there' }, '').html).toBe('<h2><b>Hi</b> there</h2>');
});
test('text changed without html (e.g. AI update_props) wins over stale html', () => {
expect(toHtml({ level: 'h2', text: 'New <copy>', html: '<b>Old</b> copy' }, '').html).toBe('<h2>New &lt;copy&gt;</h2>');
});
test.each([
['<img src=x onerror=alert(1)>', '<img'],
['<script>alert(1)</script>', '<script'],
['<a href="javascript:alert(1)">x</a>', 'javascript:'],
['<a href="jav&#x61;script:alert(1)">x</a>', 'href'],
['<b onclick="alert(1)">x</b>', 'onclick'],
['<span style="position:fixed;inset:0">x</span>', 'position'],
['</h2><iframe src="https://evil"></iframe><h2>', 'iframe'],
['<svg onload=alert(1)>', '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('<h2')).toBe(true);
expect(out.endsWith('</h2>')).toBe(true);
});
test('external _blank link gets rel=noopener at export', () => {
expect(toHtml({ level: 'h2', html: '<a href="https://e.com" target="_blank">x</a>' }, '').html)
.toBe('<h2><a href="https://e.com" target="_blank" rel="noopener">x</a></h2>');
});
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)');
});
+14 -38
View File
@@ -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<HeadingProps> = ({
text = 'Heading',
html = '',
level = 'h2',
style = {},
}) => {
@@ -42,34 +48,7 @@ export const Heading: UserComponent<HeadingProps> = ({
}));
const safeLevel = sanitizeHeadingLevel(level);
const elRef = useRef<HTMLElement | null>(null);
const editedTextRef = useRef<string | null>(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<HeadingProps> = ({
},
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}</${tag}>` };
};
@@ -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: 'a<b', html: '' }, '').html).toBe('<p>a&lt;b</p>');
});
test('marked html is emitted (html-only props)', () => {
expect(toHtml({ html: '<b>Hi</b> there' }, '').html).toBe('<p><b>Hi</b> there</p>');
});
test('html whose text mirror matches text is emitted', () => {
expect(toHtml({ text: 'Hi there', html: '<b>Hi</b> there' }, '').html).toBe('<p><b>Hi</b> there</p>');
});
test('text changed without html (e.g. AI update_props) wins over stale html', () => {
expect(toHtml({ text: 'New <copy>', html: '<b>Old</b> copy' }, '').html).toBe('<p>New &lt;copy&gt;</p>');
});
test.each([
['<img src=x onerror=alert(1)>', '<img'],
['<script>alert(1)</script>', '<script'],
['<a href="javascript:alert(1)">x</a>', 'javascript:'],
['<a href="jav&#x61;script:alert(1)">x</a>', 'href'],
['<b onclick="alert(1)">x</b>', 'onclick'],
['<span style="position:fixed;inset:0">x</span>', 'position'],
['</p><iframe src="https://evil"></iframe><p>', 'iframe'],
['<svg onload=alert(1)>', '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('<p')).toBe(true);
expect(out.endsWith('</p>')).toBe(true);
});
test('external _blank link gets rel=noopener at export', () => {
expect(toHtml({ html: '<a href="https://e.com" target="_blank">x</a>' }, '').html)
.toBe('<p><a href="https://e.com" target="_blank" rel="noopener">x</a></p>');
});
test('craft.props carries a blank html default', () => {
expect((TextBlock as any).craft.props.html).toBe('');
});
});
+12 -29
View File
@@ -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<TextBlockProps> = ({
text = 'Start typing here...',
html = '',
style = {},
}) => {
const {
@@ -27,31 +32,7 @@ export const TextBlock: UserComponent<TextBlockProps> = ({
selected: node.events.selected,
}));
const elRef = useRef<HTMLParagraphElement | null>(null);
const editedTextRef = useRef<string | null>(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 (
<p
@@ -61,8 +42,9 @@ export const TextBlock: UserComponent<TextBlockProps> = ({
}}
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: `<p${styleStr ? ` style="${styleStr}"` : ''}>${escapedText}</p>` };
};
@@ -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<string, unknown>) {
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<string, unknown>) {
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 <b>World</b>');
blur();
expect(nodeProps().html).toBe('Hello <b>World</b>');
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 <b>World</b>');
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 <i>there</i>');
deselect();
expect(nodeProps().html).toBe('Hi <i>there</i>');
expect(undoDepth()).toBe(1);
});
test('a plain edit leaves html empty (legacy shape)', () => {
mount(type, { text: 'Hello', html: '' });
select();
typeHtml('Fish &amp; 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<div>b</div>');
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('<img src=x onerror="alert(1)"><a href="javascript:alert(1)" onclick="x()">click</a> <b style="position:fixed">ok</b>');
blur();
expect(nodeProps().html).toBe('click <b>ok</b>');
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('<a href="https://example.org/">x</a>');
blur();
expect(nodeProps().html).toBe('<a href="https://example.org/" target="_blank" rel="noopener">x</a>');
});
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: '<b>Hi</b> there' });
expect(el().innerHTML).toBe('<b>Hi</b> there');
harness!.act(() => {
harness!.actions.deserialize(stateWith(type, { text: 'Page two', html: '<i>Page</i> two' }));
});
harness!.act(() => { harness!.actions.history.clear(); });
expect(el().innerHTML).toBe('<i>Page</i> two');
expect(harness!.query.history.canUndo()).toBe(false);
});
test('hostile stored html never reaches the canvas DOM', () => {
mount(type, { text: '', html: '<img src=x onerror="alert(1)">safe<script>alert(1)</script>' });
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: '<b>Hi</b> there' });
harness!.act(() => { harness!.actions.setProp('n-1', (p: any) => { p.text = 'Brand <new>'; }); });
expect(el().innerHTML).toBe('Brand &lt;new&gt;');
});
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 = '<b>Hello</b>'; 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('<b>Hello</b>');
expect(undoDepth()).toBe(1);
});
});
@@ -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<string, string> = { 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<HTMLElement | null>(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 };
}