Select text inside a selected Heading or Text block and a floating
toolbar offers B / I / U / Link / Unlink (execCommand; mousedown is
prevented so the selection survives; mod+b/i/u handled on the editable).
Storage: new `html` prop holding sanitised inline HTML, with `text` kept
as the plain mirror for AI summaries, search and legacy content.
Unmarked edits store html '' so legacy nodes keep their shape. The
allowlist (b strong i em u a br span; style=color only, hex/rgb/
var(--wsb-*); href via DOMPurify URI check AND safeUrl, unsafe links
unwrapped; target only _blank with forced rel=noopener) runs on commit,
on canvas render and again at export. If `text` was changed without
`html` (Sitesmith update_props), the stale html is ignored.
Each edit commits once (blur, or deselect if no blur came first) as one
setProp = one undo step; the old blur+deselect double commit is gone.
Heading colour default now tokenRef('text'). Sitesmith canvas summary
skips `html`.
Refs #46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
185 lines
6.7 KiB
TypeScript
185 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
};
|