Skip to content

Commit 19f95b7

Browse files
authored
feat: highlight special chars in StringField and RichTextField (#837)
1 parent b98d1f9 commit 19f95b7

7 files changed

Lines changed: 268 additions & 16 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@blinkk/root-cms': patch
3+
---
4+
5+
feat: highlight special chars in StringField and RichTextField (#837)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
.StringField__container {
2+
position: relative;
3+
}
4+
5+
.StringField__highlighter {
6+
position: absolute;
7+
top: 0;
8+
left: 0;
9+
right: 0;
10+
bottom: 0;
11+
pointer-events: none;
12+
color: transparent;
13+
white-space: pre-wrap;
14+
overflow: hidden;
15+
font-family: var(--mantine-font-family);
16+
font-size: 12px;
17+
line-height: 28px;
18+
padding: 0 10px;
19+
border: 1px solid transparent;
20+
box-sizing: border-box;
21+
z-index: 0;
22+
}
23+
24+
.StringField__highlighter--textarea {
25+
line-height: 1.55;
26+
padding-top: 10px;
27+
padding-bottom: 10px;
28+
}
29+
30+
.StringField__highlight {
31+
background-color: rgba(255, 220, 0, 0.25);
32+
outline: 1px solid rgba(200, 180, 0, 0.4);
33+
border-radius: 2px;
34+
}
35+
36+
.StringField__container {
37+
background-color: #fff;
38+
39+
.StringField__input input,
40+
.StringField__input textarea {
41+
background-color: transparent !important;
42+
position: relative;
43+
z-index: 1;
44+
}
Lines changed: 81 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,48 @@
1+
import './StringField.css';
2+
13
import {TextInput, Textarea} from '@mantine/core';
24
import {ChangeEvent} from 'preact/compat';
5+
import {useRef} from 'preact/hooks';
36
import * as schema from '../../../../core/schema.js';
47
import {useDraftDocValue} from '../../../hooks/useDraftDoc.js';
58
import {requestHighlightNode} from '../../../utils/iframe-preview.js';
69
import {FieldProps} from './FieldProps.js';
710

11+
interface HighlighterProps {
12+
value: string;
13+
variant: 'input' | 'textarea';
14+
scrollRef?: any;
15+
}
16+
17+
function Highlighter(props: HighlighterProps) {
18+
const {value, variant, scrollRef} = props;
19+
const parts = value.split(/([\u00A0\u2011])/g);
20+
return (
21+
<div
22+
ref={scrollRef}
23+
className={`StringField__highlighter StringField__highlighter--${variant}`}
24+
aria-hidden="true"
25+
>
26+
{parts.map((part, i) => {
27+
if (part === '\u00A0' || part === '\u2011') {
28+
return (
29+
<span key={i} className="StringField__highlight">
30+
{part}
31+
</span>
32+
);
33+
}
34+
return <span key={i}>{part}</span>;
35+
})}
36+
{variant === 'textarea' && value.endsWith('\n') && <br />}
37+
</div>
38+
);
39+
}
40+
841
export function StringField(props: FieldProps) {
942
const field = props.field as schema.StringField;
1043
const [value, setValue] = useDraftDocValue(props.deepKey, '');
44+
const highlighterRef = useRef<HTMLDivElement>(null);
45+
const inputHighlighterRef = useRef<HTMLDivElement>(null);
1146

1247
const onChange = (e: ChangeEvent<HTMLInputElement>) => {
1348
setValue(e.currentTarget.value);
@@ -23,29 +58,60 @@ export function StringField(props: FieldProps) {
2358
requestHighlightNode(null);
2459
};
2560

61+
const onScroll = (e: any) => {
62+
if (highlighterRef.current) {
63+
highlighterRef.current.scrollTop = e.target.scrollTop;
64+
highlighterRef.current.scrollLeft = e.target.scrollLeft;
65+
}
66+
};
67+
68+
const onInputScroll = (e: any) => {
69+
if (inputHighlighterRef.current) {
70+
inputHighlighterRef.current.scrollLeft = e.target.scrollLeft;
71+
}
72+
};
73+
2674
if (field.variant === 'textarea') {
2775
return (
28-
<Textarea
76+
<div className="StringField__container">
77+
<Highlighter
78+
value={value || ''}
79+
variant="textarea"
80+
scrollRef={highlighterRef}
81+
/>
82+
<Textarea
83+
className="StringField__input"
84+
size="xs"
85+
radius={0}
86+
autosize={field.autosize}
87+
minRows={4}
88+
maxRows={field.maxRows || 12}
89+
value={value}
90+
onChange={onChange}
91+
onFocus={onFocus}
92+
onBlur={onBlur}
93+
onScroll={onScroll}
94+
/>
95+
</div>
96+
);
97+
}
98+
return (
99+
<div className="StringField__container">
100+
<Highlighter
101+
value={value || ''}
102+
variant="input"
103+
scrollRef={inputHighlighterRef}
104+
/>
105+
<TextInput
106+
className="StringField__input"
29107
size="xs"
30108
radius={0}
31-
autosize={field.autosize}
32-
minRows={4}
33-
maxRows={field.maxRows || 12}
34109
value={value}
35110
onChange={onChange}
36111
onFocus={onFocus}
37112
onBlur={onBlur}
113+
onScroll={onInputScroll}
38114
/>
39-
);
40-
}
41-
return (
42-
<TextInput
43-
size="xs"
44-
radius={0}
45-
value={value}
46-
onChange={onChange}
47-
onFocus={onFocus}
48-
onBlur={onBlur}
49-
/>
115+
</div>
50116
);
51117
}

packages/root-cms/ui/components/RichTextEditor/lexical/LexicalEditor.css

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,4 +249,10 @@
249249
height: 1px;
250250
background-color: var(--mantine-color-gray-3, #dee2e6);
251251
margin: 4px 0;
252-
}
252+
}
253+
254+
.SpecialCharacterNode {
255+
background-color: rgba(255, 220, 0, 0.25);
256+
border-radius: 2px;
257+
outline: 1px solid rgba(200, 180, 0, 0.4);
258+
}

packages/root-cms/ui/components/RichTextEditor/lexical/LexicalEditor.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,13 @@ import {
4747
$isInlineComponentNode,
4848
InlineComponentNode,
4949
} from './nodes/InlineComponentNode.js';
50+
import {SpecialCharacterNode} from './nodes/SpecialCharacterNode.js';
5051
import {FloatingLinkEditorPlugin} from './plugins/FloatingLinkEditorPlugin.js';
5152
import {FloatingToolbarPlugin} from './plugins/FloatingToolbarPlugin.js';
5253
import {MarkdownTransformPlugin} from './plugins/MarkdownTransformPlugin.js';
5354
import {OnChangePlugin} from './plugins/OnChangePlugin.js';
5455
import {ShortcutsPlugin} from './plugins/ShortcutsPlugin.js';
56+
import {SpecialCharacterPlugin} from './plugins/SpecialCharacterPlugin.js';
5557
import {TableActionMenuPlugin} from './plugins/TableActionMenuPlugin.js';
5658
import {ToolbarPlugin} from './plugins/ToolbarPlugin.js';
5759
import {TrailingParagraphPlugin} from './plugins/TrailingParagraphPlugin.js';
@@ -72,6 +74,7 @@ const INITIAL_CONFIG: InitialConfigType = {
7274
TableRowNode,
7375
BlockComponentNode,
7476
InlineComponentNode,
77+
SpecialCharacterNode,
7578
],
7679
onError: (err: Error) => {
7780
console.error('[LexicalEditor] error:', err);
@@ -432,6 +435,7 @@ function Editor(props: EditorProps) {
432435
<TabIndentationPlugin maxIndent={7} />
433436
<MarkdownTransformPlugin />
434437
<TrailingParagraphPlugin />
438+
<SpecialCharacterPlugin />
435439
{floatingAnchorElem && (
436440
<>
437441
<FloatingToolbarPlugin
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import {addClassNamesToElement} from '@lexical/utils';
2+
import {
3+
EditorConfig,
4+
LexicalNode,
5+
NodeKey,
6+
SerializedTextNode,
7+
Spread,
8+
TextNode,
9+
} from 'lexical';
10+
11+
export type SerializedSpecialCharacterNode = Spread<
12+
{
13+
type: 'special-character';
14+
},
15+
SerializedTextNode
16+
>;
17+
18+
export class SpecialCharacterNode extends TextNode {
19+
static getType(): string {
20+
return 'special-character';
21+
}
22+
23+
static clone(node: SpecialCharacterNode): SpecialCharacterNode {
24+
return new SpecialCharacterNode(node.__text, node.__key);
25+
}
26+
27+
static importJSON(
28+
serializedNode: SerializedSpecialCharacterNode
29+
): SpecialCharacterNode {
30+
return $createSpecialCharacterNode(serializedNode.text);
31+
}
32+
33+
constructor(text: string, key?: NodeKey) {
34+
super(text, key);
35+
}
36+
37+
exportJSON(): SerializedSpecialCharacterNode {
38+
return {
39+
...super.exportJSON(),
40+
type: 'special-character',
41+
};
42+
}
43+
44+
createDOM(config: EditorConfig): HTMLElement {
45+
const dom = super.createDOM(config);
46+
addClassNamesToElement(dom, 'SpecialCharacterNode');
47+
return dom;
48+
}
49+
50+
updateDOM(
51+
prevNode: SpecialCharacterNode,
52+
dom: HTMLElement,
53+
config: EditorConfig
54+
): boolean {
55+
const updated = super.updateDOM(prevNode, dom, config);
56+
addClassNamesToElement(dom, 'SpecialCharacterNode');
57+
return updated;
58+
}
59+
60+
isTextEntity(): true {
61+
return true;
62+
}
63+
64+
canInsertTextAfter(): boolean {
65+
return false;
66+
}
67+
}
68+
69+
export function $createSpecialCharacterNode(
70+
text: string
71+
): SpecialCharacterNode {
72+
return new SpecialCharacterNode(text);
73+
}
74+
75+
export function $isSpecialCharacterNode(
76+
node: LexicalNode | null | undefined
77+
): node is SpecialCharacterNode {
78+
return node instanceof SpecialCharacterNode;
79+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
2+
import {TextNode} from 'lexical';
3+
import {useEffect} from 'preact/hooks';
4+
import {
5+
$createSpecialCharacterNode,
6+
$isSpecialCharacterNode,
7+
SpecialCharacterNode,
8+
} from '../nodes/SpecialCharacterNode.js';
9+
10+
const SPECIAL_CHARS = /[\u00A0\u2011]/;
11+
12+
export function SpecialCharacterPlugin() {
13+
const [editor] = useLexicalComposerContext();
14+
15+
useEffect(() => {
16+
if (!editor.hasNodes([SpecialCharacterNode])) {
17+
throw new Error(
18+
'SpecialCharacterPlugin: SpecialCharacterNode not registered on editor'
19+
);
20+
}
21+
22+
return editor.registerNodeTransform(TextNode, (node) => {
23+
if ($isSpecialCharacterNode(node)) {
24+
return;
25+
}
26+
27+
const text = node.getTextContent();
28+
const match = SPECIAL_CHARS.exec(text);
29+
30+
if (match) {
31+
const index = match.index;
32+
const char = match[0];
33+
let targetNode;
34+
35+
if (index === 0) {
36+
[targetNode] = node.splitText(index + 1);
37+
} else {
38+
[, targetNode] = node.splitText(index, index + 1);
39+
}
40+
41+
const specialCharNode = $createSpecialCharacterNode(char);
42+
targetNode.replace(specialCharNode);
43+
}
44+
});
45+
}, [editor]);
46+
47+
return null;
48+
}

0 commit comments

Comments
 (0)