Skip to content

Commit

Permalink
[lexical-playground] Feature: Highlight special strings with format (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
citruscai authored Nov 28, 2024
1 parent 805215b commit 0d1bb66
Show file tree
Hide file tree
Showing 11 changed files with 296 additions and 0 deletions.
96 changes: 96 additions & 0 deletions packages/lexical-playground/__tests__/e2e/SpecialTexts.spec.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import {
assertHTML,
focusEditor,
html,
initialize,
test,
waitForSelector,
} from '../utils/index.mjs';

test.describe('Special Text', () => {
test.use({shouldAllowHighlightingWithBrackets: true});
test.beforeEach(({isCollab, page, shouldAllowHighlightingWithBrackets}) =>
initialize({
isCollab,
page,
shouldAllowHighlightingWithBrackets,
}),
);
test('should handle a single special text', async ({page, isCollab}) => {
await focusEditor(page);
await page.keyboard.type('[MLH Fellowship]');
await waitForSelector(page, '.PlaygroundEditorTheme__specialText');

await assertHTML(
page,
html`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span
class="PlaygroundEditorTheme__specialText"
data-lexical-text="true">
MLH Fellowship
</span>
</p>
`,
);
});
test('should handle multiple special texts', async ({page, isCollab}) => {
await focusEditor(page);
await page.keyboard.type('[MLH Fellowship] [MLH Fellowship]');
await waitForSelector(page, '.PlaygroundEditorTheme__specialText');
await assertHTML(
page,
html`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span
class="PlaygroundEditorTheme__specialText"
data-lexical-text="true">
MLH Fellowship
</span>
<span data-lexical-text="true"></span>
<span
class="PlaygroundEditorTheme__specialText"
data-lexical-text="true">
MLH Fellowship
</span>
</p>
`,
);
});

test('should not work when the option to use brackets for highlighting is disabled', async ({
page,
isCollab,
shouldAllowHighlightingWithBrackets,
}) => {
await initialize({
isCollab,
page,
shouldAllowHighlightingWithBrackets: false,
});
await focusEditor(page);
await page.keyboard.type('[MLH Fellowship]');
await assertHTML(
page,
html`
<p
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
dir="ltr">
<span data-lexical-text="true">[MLH Fellowship]</span>
</p>
`,
);
});
});
6 changes: 6 additions & 0 deletions packages/lexical-playground/__tests__/utils/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export async function initialize({
tableCellBackgroundColor,
shouldUseLexicalContextMenu,
tableHorizontalScroll,
shouldAllowHighlightingWithBrackets,
selectionAlwaysOnDisplay,
}) {
// Tests with legacy events often fail to register keypress, so
Expand Down Expand Up @@ -116,6 +117,10 @@ export async function initialize({
appSettings.tableCellBackgroundColor = tableCellBackgroundColor;
}
appSettings.shouldUseLexicalContextMenu = !!shouldUseLexicalContextMenu;

appSettings.shouldAllowHighlightingWithBrackets =
!!shouldAllowHighlightingWithBrackets;

appSettings.selectionAlwaysOnDisplay = !!selectionAlwaysOnDisplay;

const urlParams = appSettingsToURLParams(appSettings);
Expand Down Expand Up @@ -174,6 +179,7 @@ export const test = base.extend({
isRichText: IS_RICH_TEXT,
legacyEvents: LEGACY_EVENTS,
selectionAlwaysOnDisplay: false,
shouldAllowHighlightingWithBrackets: false,
shouldUseLexicalContextMenu: false,
});

Expand Down
3 changes: 3 additions & 0 deletions packages/lexical-playground/src/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import MentionsPlugin from './plugins/MentionsPlugin';
import PageBreakPlugin from './plugins/PageBreakPlugin';
import PollPlugin from './plugins/PollPlugin';
import ShortcutsPlugin from './plugins/ShortcutsPlugin';
import SpecialTextPlugin from './plugins/SpecialTextPlugin';
import SpeechToTextPlugin from './plugins/SpeechToTextPlugin';
import TabFocusPlugin from './plugins/TabFocusPlugin';
import TableCellActionMenuPlugin from './plugins/TableActionMenuPlugin';
Expand Down Expand Up @@ -96,6 +97,7 @@ export default function Editor(): JSX.Element {
tableCellMerge,
tableCellBackgroundColor,
tableHorizontalScroll,
shouldAllowHighlightingWithBrackets,
selectionAlwaysOnDisplay,
},
} = useSettings();
Expand Down Expand Up @@ -260,6 +262,7 @@ export default function Editor(): JSX.Element {
{isAutocomplete && <AutocompletePlugin />}
<div>{showTableOfContents && <TableOfContentsPlugin />}</div>
{shouldUseLexicalContextMenu && <ContextMenuPlugin />}
{shouldAllowHighlightingWithBrackets && <SpecialTextPlugin />}
<ActionsPlugin
isRichText={isRichText}
shouldPreserveNewLinesInMarkdown={shouldPreserveNewLinesInMarkdown}
Expand Down
12 changes: 12 additions & 0 deletions packages/lexical-playground/src/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default function Settings(): JSX.Element {
showTableOfContents,
shouldUseLexicalContextMenu,
shouldPreserveNewLinesInMarkdown,
shouldAllowHighlightingWithBrackets,
// tableHorizontalScroll,
selectionAlwaysOnDisplay,
},
Expand Down Expand Up @@ -176,6 +177,17 @@ export default function Settings(): JSX.Element {
checked={tableHorizontalScroll}
text="Tables have horizontal scroll"
/> */}
<Switch
onClick={() => {
setOption(
'shouldAllowHighlightingWithBrackets',
!shouldAllowHighlightingWithBrackets,
);
}}
checked={shouldAllowHighlightingWithBrackets}
text="Use Brackets for Highlighting"
/>

<Switch
onClick={() => {
setOption('selectionAlwaysOnDisplay', !selectionAlwaysOnDisplay);
Expand Down
1 change: 1 addition & 0 deletions packages/lexical-playground/src/appSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const DEFAULT_SETTINGS = {
isRichText: true,
measureTypingPerf: false,
selectionAlwaysOnDisplay: false,
shouldAllowHighlightingWithBrackets: false,
shouldPreserveNewLinesInMarkdown: false,
shouldUseLexicalContextMenu: false,
showNestedEditorTreeView: false,
Expand Down
2 changes: 2 additions & 0 deletions packages/lexical-playground/src/nodes/PlaygroundNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {LayoutItemNode} from './LayoutItemNode';
import {MentionNode} from './MentionNode';
import {PageBreakNode} from './PageBreakNode';
import {PollNode} from './PollNode';
import {SpecialTextNode} from './SpecialTextNode';
import {StickyNode} from './StickyNode';
import {TweetNode} from './TweetNode';
import {YouTubeNode} from './YouTubeNode';
Expand Down Expand Up @@ -73,6 +74,7 @@ const PlaygroundNodes: Array<Klass<LexicalNode>> = [
PageBreakNode,
LayoutContainerNode,
LayoutItemNode,
SpecialTextNode,
];

export default PlaygroundNodes;
97 changes: 97 additions & 0 deletions packages/lexical-playground/src/nodes/SpecialTextNode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/

import type {
EditorConfig,
LexicalNode,
NodeKey,
SerializedTextNode,
} from 'lexical';

import {addClassNamesToElement} from '@lexical/utils';
import {$applyNodeReplacement, TextNode} from 'lexical';

/** @noInheritDoc */
export class SpecialTextNode extends TextNode {
static getType(): string {
return 'specialText';
}

static clone(node: SpecialTextNode): SpecialTextNode {
return new SpecialTextNode(node.__text, node.__key);
}

constructor(text: string, key?: NodeKey) {
super(text, key);
}

createDOM(config: EditorConfig): HTMLElement {
const dom = document.createElement('span');
addClassNamesToElement(dom, config.theme.specialText);
dom.textContent = this.getTextContent();
return dom;
}

updateDOM(
prevNode: TextNode,
dom: HTMLElement,
config: EditorConfig,
): boolean {
if (prevNode.__text.startsWith('[') && prevNode.__text.endsWith(']')) {
const strippedText = this.__text.substring(1, this.__text.length - 1); // Strip brackets again
dom.textContent = strippedText; // Update the text content
}

addClassNamesToElement(dom, config.theme.specialText);

return false;
}

static importJSON(serializedNode: SerializedTextNode): SpecialTextNode {
const node = $createSpecialTextNode(serializedNode.text);
node.setFormat(serializedNode.format);
node.setStyle(serializedNode.style);
node.setDetail(serializedNode.detail);
node.setMode(serializedNode.mode);
return node;
}

exportJSON(): SerializedTextNode {
return {
...super.exportJSON(),
type: 'specialText',
};
}

isTextEntity(): true {
return true;
}
canInsertTextAfter(): boolean {
return false; // Prevents appending text to this node
}
}

/**
* Creates a SpecialTextNode with the given text.
* @param text - Text content for the SpecialTextNode.
* @returns A new SpecialTextNode instance.
*/
export function $createSpecialTextNode(text = ''): SpecialTextNode {
return $applyNodeReplacement(new SpecialTextNode(text));
}

/**
* Checks if a node is a SpecialTextNode.
* @param node - Node to check.
* @returns True if the node is a SpecialTextNode.
*/
export function $isSpecialTextNode(
node: LexicalNode | null | undefined,
): node is SpecialTextNode {
return node instanceof SpecialTextNode;
}
72 changes: 72 additions & 0 deletions packages/lexical-playground/src/plugins/SpecialTextPlugin/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
import type {LexicalEditor} from 'lexical';

import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
import {TextNode} from 'lexical';
import {useEffect} from 'react';

import {
$createSpecialTextNode,
SpecialTextNode,
} from '../../nodes/SpecialTextNode';

const BRACKETED_TEXT_REGEX = /\[([^\[\]]+)\]/; // eslint-disable-line

function $findAndTransformText(node: TextNode): null | TextNode {
const text = node.getTextContent();

const match = BRACKETED_TEXT_REGEX.exec(text);
if (match) {
const matchedText = match[1];
const startIndex = match.index;

let targetNode;
if (startIndex === 0) {
[targetNode] = node.splitText(startIndex + match[0].length);
} else {
[, targetNode] = node.splitText(startIndex, startIndex + match[0].length);
}

const specialTextNode = $createSpecialTextNode(matchedText);
targetNode.replace(specialTextNode);
return specialTextNode;
}

return null;
}

function $textNodeTransform(node: TextNode): void {
let targetNode: TextNode | null = node;

while (targetNode !== null) {
if (!targetNode.isSimpleText()) {
return;
}

targetNode = $findAndTransformText(targetNode);
}
}

function useTextTransformation(editor: LexicalEditor): void {
useEffect(() => {
if (!editor.hasNodes([SpecialTextNode])) {
throw new Error(
'SpecialTextPlugin: SpecialTextNode not registered on editor',
);
}

return editor.registerNodeTransform(TextNode, $textNodeTransform);
}, [editor]);
}

export default function SpecialTextPlugin(): JSX.Element | null {
const [editor] = useLexicalComposerContext();
useTextTransformation(editor);
return null;
}
Original file line number Diff line number Diff line change
Expand Up @@ -448,3 +448,8 @@
outline: 2px solid rgb(60, 132, 244);
user-select: none;
}

.PlaygroundEditorTheme__specialText {
background-color: yellow;
font-weight: bold;
}
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ const theme: EditorThemeClasses = {
paragraph: 'PlaygroundEditorTheme__paragraph',
quote: 'PlaygroundEditorTheme__quote',
rtl: 'PlaygroundEditorTheme__rtl',
specialText: 'PlaygroundEditorTheme__specialText',
table: 'PlaygroundEditorTheme__table',
tableCell: 'PlaygroundEditorTheme__tableCell',
tableCellActionButton: 'PlaygroundEditorTheme__tableCellActionButton',
Expand Down
1 change: 1 addition & 0 deletions packages/lexical/src/LexicalEditor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export type EditorThemeClasses = {
code?: EditorThemeClassName;
codeHighlight?: Record<string, EditorThemeClassName>;
hashtag?: EditorThemeClassName;
specialText?: EditorThemeClassName;
heading?: {
h1?: EditorThemeClassName;
h2?: EditorThemeClassName;
Expand Down

0 comments on commit 0d1bb66

Please sign in to comment.