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