# 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
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user