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

+2
View File
@@ -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 />
</>
);
+174
View File
@@ -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);
});
});
+184
View File
@@ -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>
);
};