Files
site-builder/craft/src/editor/RichTextToolbar.tsx
T
shadowdaoandClaude Opus 5.5 7e8812a752 feat(site-builder): inline bold/italic/underline/links on Heading and Text
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>
2026-10-07 11:41:56 -07:00

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>
);
};