# Conflicts: # craft/src/editor/CanvasOverlays.tsx # craft/src/styles/editor.css
This commit is contained in:
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<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 <copy></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="javascript: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)');
|
||||
});
|
||||
@@ -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<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 <copy></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="javascript: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('');
|
||||
});
|
||||
});
|
||||
@@ -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 & 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 <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 = '<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 };
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { NoticeHost } from './NoticeHost';
|
||||
import { EmptyContainerAdd } from './EmptyContainerAdd';
|
||||
import { DropIndicator } from './DropIndicator';
|
||||
import { RichTextToolbar } from './RichTextToolbar';
|
||||
|
||||
/**
|
||||
* Single mount point for editor-wide overlays rendered next to the shell.
|
||||
@@ -13,5 +14,6 @@ export const CanvasOverlays: React.FC = () => (
|
||||
<NoticeHost />
|
||||
<EmptyContainerAdd />
|
||||
<DropIndicator />
|
||||
<RichTextToolbar />
|
||||
</>
|
||||
);
|
||||
@@ -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<typeof vi.fn>;
|
||||
|
||||
afterEach(() => {
|
||||
window.getSelection()?.removeAllRanges();
|
||||
delete (document as any).execCommand;
|
||||
harness?.unmount();
|
||||
harness = null;
|
||||
});
|
||||
|
||||
function setup() {
|
||||
harness = renderEditorHarness({ initialState: STATE });
|
||||
harness.mountChild(<RichTextToolbar />);
|
||||
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 = '<b>Hello</b> 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('<b>Hello</b> 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);
|
||||
});
|
||||
});
|
||||
@@ -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<Placement | null>(null);
|
||||
const [linkOpen, setLinkOpen] = useState(false);
|
||||
const [linkValue, setLinkValue] = useState('');
|
||||
const [linkError, setLinkError] = useState(false);
|
||||
const editableRef = useRef<HTMLElement | null>(null);
|
||||
const savedRangeRef = useRef<Range | null>(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) => (
|
||||
<button type="button" className="rt-toolbar-btn" aria-label={label} title={label} onMouseDown={keep} onClick={onClick}>
|
||||
<i className={`fa ${icon}`} aria-hidden="true" />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rt-toolbar"
|
||||
role="toolbar"
|
||||
aria-label="Text formatting"
|
||||
style={{ top: placement.top, left: placement.left }}
|
||||
>
|
||||
{linkOpen ? (
|
||||
<form
|
||||
className="rt-toolbar-link"
|
||||
onSubmit={(e) => { e.preventDefault(); applyLink(); }}
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
type="text"
|
||||
className={`rt-toolbar-input${linkError ? ' is-invalid' : ''}`}
|
||||
aria-label="Link URL"
|
||||
aria-invalid={linkError || undefined}
|
||||
placeholder="https://example.com"
|
||||
value={linkValue}
|
||||
onChange={(e) => { setLinkValue(e.target.value); setLinkError(false); }}
|
||||
onKeyDown={(e) => { if (e.key === 'Escape') { e.preventDefault(); closeLink(true); } }}
|
||||
/>
|
||||
<button type="submit" className="rt-toolbar-btn" aria-label="Apply link" title="Apply link">
|
||||
<i className="fa fa-check" aria-hidden="true" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
{button('Bold', 'fa-bold', () => run('bold'))}
|
||||
{button('Italic', 'fa-italic', () => run('italic'))}
|
||||
{button('Underline', 'fa-underline', () => run('underline'))}
|
||||
{button('Link', 'fa-link', openLink)}
|
||||
{button('Remove link', 'fa-unlink', () => run('unlink'))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -2441,3 +2441,64 @@ body {
|
||||
background: var(--color-accent-subtle);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* #46 rich text toolbar -- floating B/I/U/Link bar over a canvas text selection */
|
||||
.rt-toolbar {
|
||||
position: fixed;
|
||||
z-index: var(--z-selection-toolbar);
|
||||
transform: translate(-50%, -100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
background: var(--color-bg-elevated);
|
||||
border: 1px solid var(--color-border-light);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.rt-toolbar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rt-toolbar-btn:hover,
|
||||
.rt-toolbar-btn:focus-visible {
|
||||
background: var(--color-bg-hover);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rt-toolbar-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rt-toolbar-input {
|
||||
width: 220px;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg-base);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.rt-toolbar-input:focus {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.rt-toolbar-input.is-invalid {
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
@@ -22,3 +22,21 @@ describe('summarizeCanvas', () => {
|
||||
expect(out).toContain('truncated');
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeCanvas with inline rich text (#46)', () => {
|
||||
test('summarises the plain text mirror, never the html markup', () => {
|
||||
const state = {
|
||||
ROOT: { type: { resolvedName: 'Container' }, props: {}, nodes: ['h'] },
|
||||
h: {
|
||||
type: { resolvedName: 'Heading' },
|
||||
props: { text: 'Hello World', html: 'Hello <b>World</b>', level: 'h2' },
|
||||
nodes: [],
|
||||
},
|
||||
};
|
||||
const out = summarizeCanvas(state as any);
|
||||
expect(out).toContain('text=Hello World');
|
||||
expect(out).toContain('level=h2');
|
||||
expect(out).not.toContain('<b>');
|
||||
expect(out).not.toContain('html=');
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,8 @@ export function summarizeCanvas(state: SerializedNodes, maxChars = 6000): string
|
||||
const nodeId = (props as any).node_id ?? id;
|
||||
const interesting: string[] = [];
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
if (k === 'aiName' || k === 'node_id' || k === 'style') continue;
|
||||
// `html` (#46) is markup for the plain `text` mirror -- summarise text only.
|
||||
if (k === 'aiName' || k === 'node_id' || k === 'style' || k === 'html') continue;
|
||||
if (v == null) continue;
|
||||
const repr = typeof v === 'string' ? v : JSON.stringify(v);
|
||||
const truncated = repr.length > 60 ? repr.slice(0, 57) + '…' : repr;
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, test, expect } from 'vitest';
|
||||
import {
|
||||
sanitizeInlineHtml,
|
||||
inlineHtmlToText,
|
||||
textToInlineHtml,
|
||||
isPlainInlineHtml,
|
||||
} from './inline-html';
|
||||
|
||||
describe('sanitizeInlineHtml allowlist', () => {
|
||||
test.each([
|
||||
['<script>alert(1)</script>hi', 'hi'],
|
||||
['<b onclick="alert(1)">x</b>', '<b>x</b>'],
|
||||
['<span style="position:fixed;top:0">x</span>', 'x'],
|
||||
['<span style="color:var(--wsb-primary)">x</span>', '<span style="color:var(--wsb-primary)">x</span>'],
|
||||
['<span style="color:#ff0000">x</span>', '<span style="color:#ff0000">x</span>'],
|
||||
['<span style="color: rgb(255, 0, 0);">x</span>', '<span style="color:rgb(255, 0, 0)">x</span>'],
|
||||
['<span style="color:red;position:fixed">x</span>', 'x'],
|
||||
['<span style="color:expression(alert(1))">x</span>', 'x'],
|
||||
['<span style="color:var(--evil)">x</span>', 'x'],
|
||||
['<span style="color:#fff;background:url(javascript:alert(1))">x</span>', '<span style="color:#fff">x</span>'],
|
||||
['<b>x</b> <strong>y</strong> <i>z</i> <em>w</em> <u>v</u>', '<b>x</b> <strong>y</strong> <i>z</i> <em>w</em> <u>v</u>'],
|
||||
['<div>outer <div>inner</div></div>', 'outer <br>inner'],
|
||||
['a<div>b</div>', 'a<br>b'],
|
||||
['<img src=x onerror=alert(1)>t', 't'],
|
||||
['<svg><script>alert(1)</script></svg>t', 't'],
|
||||
['<iframe src="https://e.com"></iframe>t', 't'],
|
||||
['<b data-x="1" id="a" class="c">x</b>', '<b>x</b>'],
|
||||
['<script>alert(1)</script>', '<script>alert(1)</script>'],
|
||||
['<style>body{display:none}</style>t', 't'],
|
||||
['<b>x</b><h2>unwrapped</h2>', '<b>x</b>unwrapped'],
|
||||
])('%s -> %s', (input, expected) => {
|
||||
expect(sanitizeInlineHtml(input)).toBe(expected);
|
||||
});
|
||||
|
||||
test('empty / non-string input -> empty string', () => {
|
||||
expect(sanitizeInlineHtml('')).toBe('');
|
||||
expect(sanitizeInlineHtml(' ')).toBe('');
|
||||
expect(sanitizeInlineHtml(undefined as any)).toBe('');
|
||||
expect(sanitizeInlineHtml({} as any)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeInlineHtml links', () => {
|
||||
test.each([
|
||||
'<a href="javascript:alert(1)">x</a>',
|
||||
'<a href="JaVaScRiPt:alert(1)">x</a>',
|
||||
'<a href=" javascript:alert(1)">x</a>',
|
||||
'<a href="javascript:alert(1)">x</a>',
|
||||
'<a href="javascript:alert(1)">x</a>',
|
||||
'<a href="java\tscript:alert(1)">x</a>',
|
||||
'<a href="vbscript:msgbox(1)">x</a>',
|
||||
'<a href="data:text/html,<script>alert(1)</script>">x</a>',
|
||||
'<a href="data:image/svg+xml;base64,PHN2Zz4=">x</a>',
|
||||
'<a>x</a>',
|
||||
])('unsafe/missing href unwraps to text: %s', (input) => {
|
||||
expect(sanitizeInlineHtml(input)).toBe('x');
|
||||
});
|
||||
|
||||
test('target=_blank forces rel=noopener (author rel is replaced)', () => {
|
||||
expect(sanitizeInlineHtml('<a href="https://e.com" target="_blank">x</a>'))
|
||||
.toBe('<a href="https://e.com" target="_blank" rel="noopener">x</a>');
|
||||
expect(sanitizeInlineHtml('<a href="https://e.com" target="_blank" rel="opener">x</a>'))
|
||||
.toBe('<a href="https://e.com" target="_blank" rel="noopener">x</a>');
|
||||
});
|
||||
|
||||
test('non-_blank targets and stray rel are dropped', () => {
|
||||
expect(sanitizeInlineHtml('<a href="/about" target="_top" rel="x">x</a>')).toBe('<a href="/about">x</a>');
|
||||
});
|
||||
|
||||
test('href/target/rel on a non-anchor are stripped', () => {
|
||||
expect(sanitizeInlineHtml('<b href="https://e.com" target="_blank">x</b>')).toBe('<b>x</b>');
|
||||
});
|
||||
|
||||
test('safe relative, mailto and https links survive', () => {
|
||||
expect(sanitizeInlineHtml('<a href="mailto:a@b.co">m</a>')).toBe('<a href="mailto:a@b.co">m</a>');
|
||||
expect(sanitizeInlineHtml('<a href="https://e.com/?a=1&b=2">q</a>')).toBe('<a href="https://e.com/?a=1&b=2">q</a>');
|
||||
});
|
||||
|
||||
test('a link carrying an event handler keeps only the href', () => {
|
||||
expect(sanitizeInlineHtml('<a href="https://e.com" onmouseover="alert(1)">x</a>')).toBe('<a href="https://e.com">x</a>');
|
||||
});
|
||||
|
||||
test('quote breakout in an attribute cannot inject markup', () => {
|
||||
const out = sanitizeInlineHtml('<a href="https://e.com/"onmouseover="alert(1)">x</a>');
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = out;
|
||||
expect(tpl.content.querySelector('a')!.getAttributeNames()).toEqual(['href']);
|
||||
});
|
||||
|
||||
test('output is idempotent', () => {
|
||||
const once = sanitizeInlineHtml('<a href="https://e.com" target="_blank">x</a> <b>y</b><div>z</div>');
|
||||
expect(sanitizeInlineHtml(once)).toBe(once);
|
||||
});
|
||||
});
|
||||
|
||||
describe('text <-> inline html', () => {
|
||||
test.each(['plain', 'a<b & "c" \'d\' >e', 'line 1\nline 2', '<already>', '', 'x\n\ny'])(
|
||||
'round-trip inlineHtmlToText(textToInlineHtml(%j))',
|
||||
(s) => {
|
||||
expect(inlineHtmlToText(textToInlineHtml(s))).toBe(s);
|
||||
},
|
||||
);
|
||||
|
||||
test('inlineHtmlToText strips tags, decodes entities, <br> -> newline', () => {
|
||||
expect(inlineHtmlToText('Hello <b>World</b>')).toBe('Hello World');
|
||||
expect(inlineHtmlToText('a & b<br>c <d>')).toBe('a & b\nc <d>');
|
||||
});
|
||||
|
||||
test('textToInlineHtml escapes and turns newlines into <br>', () => {
|
||||
expect(textToInlineHtml('a<b\nc')).toBe('a<b<br>c');
|
||||
});
|
||||
|
||||
test('isPlainInlineHtml: only text and <br> count as no marks', () => {
|
||||
expect(isPlainInlineHtml('a & b<br>c')).toBe(true);
|
||||
expect(isPlainInlineHtml('a <b>b</b>')).toBe(false);
|
||||
expect(isPlainInlineHtml('<br>')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import createDOMPurify from 'dompurify';
|
||||
import { escapeHtml, safeUrl } from './escape';
|
||||
|
||||
/**
|
||||
* Inline rich text (#46) for Heading / Text blocks.
|
||||
*
|
||||
* Stored on the `html` prop and emitted RAW into the published page, so it is
|
||||
* an XSS sink: everything goes through `sanitizeInlineHtml`, both when the
|
||||
* editor commits an edit and again at export (defence in depth -- a saved
|
||||
* blob or an AI `update_props` op never passed through the editor).
|
||||
*
|
||||
* Allowlist only: b/strong/i/em/u/a/br/span; `style` may carry `color` and
|
||||
* nothing else (hex, rgb()/rgba() or a `var(--wsb-*)` token); `a href` must
|
||||
* pass `safeUrl` or the link is unwrapped to its text; `target` is only ever
|
||||
* `_blank` and then always gets `rel="noopener"`.
|
||||
*/
|
||||
|
||||
export const INLINE_ALLOWED_TAGS = ['b', 'strong', 'i', 'em', 'u', 'a', 'br', 'span'];
|
||||
export const INLINE_ALLOWED_ATTR = ['href', 'target', 'rel', 'style'];
|
||||
|
||||
// A private DOMPurify instance: HtmlBlock's `purifyHtml` adds/removes a hook
|
||||
// on the shared default instance, and nothing here may ever inherit it (or
|
||||
// leak into it).
|
||||
let purifier: ReturnType<typeof createDOMPurify> | null = null;
|
||||
function getPurifier() {
|
||||
if (!purifier) purifier = createDOMPurify(window);
|
||||
return purifier;
|
||||
}
|
||||
|
||||
const PURIFY_CONFIG = {
|
||||
ALLOWED_TAGS: INLINE_ALLOWED_TAGS,
|
||||
ALLOWED_ATTR: INLINE_ALLOWED_ATTR,
|
||||
ALLOW_DATA_ATTR: false,
|
||||
ALLOW_ARIA_ATTR: false,
|
||||
KEEP_CONTENT: true,
|
||||
RETURN_DOM_FRAGMENT: true,
|
||||
};
|
||||
|
||||
const SAFE_COLOR_RE = new RegExp(
|
||||
'^(?:' +
|
||||
'#[0-9a-f]{3,8}' +
|
||||
'|var\\(--wsb-[a-z0-9-]+\\)' +
|
||||
'|rgba?\\(\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*,\\s*\\d{1,3}\\s*(?:,\\s*(?:0|1|0?\\.\\d+)\\s*)?\\)' +
|
||||
')$',
|
||||
'i',
|
||||
);
|
||||
|
||||
/** The `color` value of an inline style string if it is a safe colour, else ''. */
|
||||
function safeColorFromStyle(style: string): string {
|
||||
let color = '';
|
||||
for (const decl of style.split(';')) {
|
||||
const idx = decl.indexOf(':');
|
||||
if (idx < 0) continue;
|
||||
if (decl.slice(0, idx).trim().toLowerCase() !== 'color') continue;
|
||||
const value = decl.slice(idx + 1).trim();
|
||||
color = SAFE_COLOR_RE.test(value) ? value : '';
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
function unwrap(el: Element): void {
|
||||
const parent = el.parentNode;
|
||||
if (!parent) return;
|
||||
while (el.firstChild) parent.insertBefore(el.firstChild, el);
|
||||
parent.removeChild(el);
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn block boundaries that contentEditable inserts on Enter
|
||||
* (`a<div>b</div>`, `<p>…</p>`) into `<br>` so a multi-line edit keeps its
|
||||
* line breaks once the block tags are unwrapped by the allowlist. Runs on an
|
||||
* inert <template> (no script, no resource loads) before DOMPurify.
|
||||
*/
|
||||
function blocksToBreaks(html: string): string {
|
||||
if (!/<(div|p)\b/i.test(html)) return html;
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = html;
|
||||
const blocks = Array.from(tpl.content.querySelectorAll('div, p')).reverse();
|
||||
for (const el of blocks) {
|
||||
if (el.previousSibling) el.parentNode?.insertBefore(document.createElement('br'), el);
|
||||
}
|
||||
return tpl.innerHTML;
|
||||
}
|
||||
|
||||
/** Sanitise inline rich text to the allowlist above. '' for empty/non-string. */
|
||||
export function sanitizeInlineHtml(html: string): string {
|
||||
if (typeof html !== 'string' || html.trim() === '') return '';
|
||||
const frag = getPurifier().sanitize(blocksToBreaks(html), PURIFY_CONFIG as any) as unknown as DocumentFragment;
|
||||
|
||||
frag.querySelectorAll('[style]').forEach((el) => {
|
||||
const color = safeColorFromStyle(el.getAttribute('style') || '');
|
||||
if (color) el.setAttribute('style', `color:${color}`);
|
||||
else el.removeAttribute('style');
|
||||
});
|
||||
|
||||
// Only <a> may carry href/target/rel.
|
||||
frag.querySelectorAll('[href],[target],[rel]').forEach((el) => {
|
||||
if (el.tagName !== 'A') {
|
||||
el.removeAttribute('href');
|
||||
el.removeAttribute('target');
|
||||
el.removeAttribute('rel');
|
||||
}
|
||||
});
|
||||
|
||||
frag.querySelectorAll('a').forEach((a) => {
|
||||
const href = safeUrl(a.getAttribute('href') || '');
|
||||
if (!href) { unwrap(a); return; }
|
||||
a.setAttribute('href', href);
|
||||
const blank = a.getAttribute('target') === '_blank';
|
||||
a.removeAttribute('target');
|
||||
a.removeAttribute('rel');
|
||||
if (blank) {
|
||||
a.setAttribute('target', '_blank');
|
||||
a.setAttribute('rel', 'noopener');
|
||||
}
|
||||
});
|
||||
|
||||
// A span without a (surviving) colour carries nothing -- drop the wrapper.
|
||||
frag.querySelectorAll('span:not([style])').forEach(unwrap);
|
||||
|
||||
const box = document.createElement('div');
|
||||
box.appendChild(frag);
|
||||
return box.innerHTML;
|
||||
}
|
||||
|
||||
/** True when sanitised inline html carries no marks (only text and <br>). */
|
||||
export function isPlainInlineHtml(html: string): boolean {
|
||||
return !/<(?!br\b)[a-z]/i.test(html);
|
||||
}
|
||||
|
||||
/** Plain-text mirror of inline html: tags stripped, entities decoded, <br> -> newline. */
|
||||
export function inlineHtmlToText(html: string): string {
|
||||
if (typeof html !== 'string' || html === '') return '';
|
||||
const tpl = document.createElement('template');
|
||||
tpl.innerHTML = html;
|
||||
tpl.content.querySelectorAll('br').forEach((br) => br.replaceWith('\n'));
|
||||
return tpl.content.textContent || '';
|
||||
}
|
||||
|
||||
/** Legacy plain `text` rendered as inline html: escaped, newlines -> <br>. */
|
||||
export function textToInlineHtml(text: string): string {
|
||||
return escapeHtml(text ?? '').replace(/\r\n?|\n/g, '<br>');
|
||||
}
|
||||
|
||||
/**
|
||||
* The sanitised rich html to show for a Heading/Text node, or null when the
|
||||
* plain `text` should be used instead. `text` is the plain mirror of `html`;
|
||||
* when a writer that only knows `text` (Sitesmith `update_props`, an older
|
||||
* saved blob) changes it, the mirror no longer matches and the stale `html`
|
||||
* is ignored -- `text` wins. A missing/empty `text` never overrides `html`.
|
||||
*/
|
||||
export function resolveInlineHtml(text: unknown, html: unknown): string | null {
|
||||
if (typeof html !== 'string' || html === '') return null;
|
||||
const clean = sanitizeInlineHtml(html);
|
||||
if (clean === '') return null;
|
||||
if (typeof text === 'string' && text !== '' && inlineHtmlToText(clean) !== text) return null;
|
||||
return clean;
|
||||
}
|
||||
|
||||
/** Give absolute http(s) links to another host `target="_blank"` (used on commit). */
|
||||
export function markExternalLinks(root: ParentNode): void {
|
||||
root.querySelectorAll('a[href]').forEach((a) => {
|
||||
if (a.hasAttribute('target')) return;
|
||||
try {
|
||||
const url = new URL(a.getAttribute('href') || '', window.location.href);
|
||||
if (/^https?:$/.test(url.protocol) && url.host !== window.location.host) {
|
||||
a.setAttribute('target', '_blank');
|
||||
}
|
||||
} catch { /* unparseable href: leave as-is, the sanitiser decides */ }
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user