Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@
- **Serialization** - Import/export from JSON, Markdown, and HTML
- **Rich Content** - Support for tables, lists, code blocks, images, and custom nodes
- **Cross-browser** - Firefox 115+, Safari 15+, Chrome 86+ (see [Supported Browsers](https://lexical.dev/docs/getting-started/supported-browsers))

- **Type Safe** - Written in TypeScript with comprehensive type definitions

## Quick Start
Expand Down Expand Up @@ -109,10 +108,10 @@ See [CONTRIBUTING.md](https://github.com/facebook/lexical/blob/main/CONTRIBUTING

| Browser | Version |
|---------|---------|
| Chrome | 49+ |
| Firefox | 52+ |
| Safari | 11+ |
| Edge | 79+ |
| Chrome | 86+ |
| Firefox | 115+ |
| Safari | 15+ |
| Edge | 86+ |

## Contributors

Expand Down
22 changes: 22 additions & 0 deletions packages/lexical-extension/flow/LexicalExtension.js.flow
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {
EditorSetOptions,
InitialEditorConfig,
SerializedLexicalNode,
TextFormatType
} from 'lexical';
import {DecoratorNode} from 'lexical';
export type {
Expand Down Expand Up @@ -185,6 +186,27 @@ declare export function watchedSignal<T>(
register: (self: Signal<T>) => () => void,
): Signal<T>;

export type SerializedDecoratorTextNode = {
...SerializedLexicalNode,
format: number,
...
};
declare export class DecoratorTextNode<T> extends DecoratorNode<T> {
createDOM(): HTMLElement;
getFormat(): number;
getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number;
hasFormat(type: TextFormatType): boolean;
setFormat(type: number): this;
toggleFormat(type: TextFormatType): this;
isInline(): true;
};

declare export function $isDecoratorTextNode(
node: ?LexicalNode,
): node is DecoratorTextNode<{...}>;

declare export var DecoratorTextExtension: LexicalExtension<ExtensionConfigBase, "@lexical/extension/DecoratorText", void, void>;

declare export function registerClearEditor(
editor: LexicalEditor,
$onClear?: () => void,
Expand Down
217 changes: 217 additions & 0 deletions packages/lexical-extension/src/DecoratorTextExtension.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
/**
* 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 {
LexicalNode,
SerializedLexicalNode,
Spread,
StateConfigValue,
StateValueOrUpdater,
TextFormatType,
} from 'lexical';
import type {JSX} from 'react';

import {
$getSelection,
$getState,
$isNodeSelection,
$isRangeSelection,
$setState,
COMMAND_PRIORITY_LOW,
createState,
DecoratorNode,
defineExtension,
FORMAT_TEXT_COMMAND,
TEXT_TYPE_TO_FORMAT,
toggleTextFormatType,
} from 'lexical';

export type SerializedDecoratorTextNode = Spread<
{
format: number;
},
SerializedLexicalNode
>;

const formatState = createState('format', {
parse: (value) => (typeof value === 'number' ? value : 0),
});

export class DecoratorTextNode extends DecoratorNode<JSX.Element> {
$config() {
return this.config('decorator-text', {
extends: DecoratorNode,
stateConfigs: [{flat: true, stateConfig: formatState}],
});
}

getFormat(): StateConfigValue<typeof formatState> {
return $getState(this, formatState);
}

getFormatFlags(type: TextFormatType, alignWithFormat: null | number): number {
return toggleTextFormatType(this.getFormat(), type, alignWithFormat);
}

hasFormat(type: TextFormatType): boolean {
const formatFlag = TEXT_TYPE_TO_FORMAT[type];
return (this.getFormat() & formatFlag) !== 0;
}

setFormat(type: StateValueOrUpdater<typeof formatState>): this {
return $setState(this, formatState, type);
}

toggleFormat(type: TextFormatType): this {
const format = this.getFormat();
const newFormat = toggleTextFormatType(format, type, null);
return this.setFormat(newFormat);
}

isInline(): true {
return true;
}

createDOM(): HTMLElement {
return document.createElement('span');
}

updateDOM(): false {
return false;
}
}

export function $isDecoratorTextNode(
node: LexicalNode | null | undefined,
): node is DecoratorTextNode {
return node instanceof DecoratorTextNode;
}

/**
* Applies formatting to the node based on the properties in the passed style object.
* By default, properties are checked according to the values set
* when importing content from Google Docs.
* This algorithm is identical to the TextNode import.

* @param lexicalNode The node to which the format will apply
* @param style CSS style object
* @param shouldApply format to apply if it is not in style
* @returns lexicalNode
*/
export function applyFormatFromStyle(
lexicalNode: DecoratorTextNode,
style: CSSStyleDeclaration,
shouldApply?: TextFormatType,
) {
const fontWeight = style.fontWeight;
const textDecoration = style.textDecoration.split(' ');
// Google Docs uses span tags + font-weight for bold text
const hasBoldFontWeight = fontWeight === '700' || fontWeight === 'bold';
// Google Docs uses span tags + text-decoration: line-through for strikethrough text
const hasLinethroughTextDecoration = textDecoration.includes('line-through');
// Google Docs uses span tags + font-style for italic text
const hasItalicFontStyle = style.fontStyle === 'italic';
// Google Docs uses span tags + text-decoration: underline for underline text
const hasUnderlineTextDecoration = textDecoration.includes('underline');
// Google Docs uses span tags + vertical-align to specify subscript and superscript
const verticalAlign = style.verticalAlign;

if (hasBoldFontWeight && !lexicalNode.hasFormat('bold')) {
lexicalNode.toggleFormat('bold');
}
if (hasLinethroughTextDecoration && !lexicalNode.hasFormat('strikethrough')) {
lexicalNode.toggleFormat('strikethrough');
}
if (hasItalicFontStyle && !lexicalNode.hasFormat('italic')) {
lexicalNode.toggleFormat('italic');
}
if (hasUnderlineTextDecoration && !lexicalNode.hasFormat('underline')) {
lexicalNode.toggleFormat('underline');
}
if (verticalAlign === 'sub' && !lexicalNode.hasFormat('subscript')) {
lexicalNode.toggleFormat('subscript');
}
if (verticalAlign === 'super' && !lexicalNode.hasFormat('superscript')) {
lexicalNode.toggleFormat('superscript');
}
if (shouldApply && !lexicalNode.hasFormat(shouldApply)) {
lexicalNode.toggleFormat(shouldApply);
}

return lexicalNode;
}

/**
* The function wraps the passed DOM node in semantic tags depending on the node format.
*
* @param lexicalNode The node where the format is checked
* @param domNode DOM that will be wrapped in tags
* @param tagNameToFormat Tag name and format mapping
* @returns domNode
*/
export function applyFormatToDom(
lexicalNode: DecoratorTextNode,
domNode: Text | HTMLElement,
tagNameToFormat = DEFAULT_TAG_NAME_TO_FORMAT,
) {
for (const [tag, format] of Object.entries(tagNameToFormat)) {
if (lexicalNode.hasFormat(format)) {
domNode = wrapElementWith(domNode, tag);
}
}
return domNode;
}

function wrapElementWith(
element: HTMLElement | Text,
tag: string,
): HTMLElement {
const el = document.createElement(tag);
el.appendChild(element);
return el;
}

const DEFAULT_TAG_NAME_TO_FORMAT: {[key: string]: TextFormatType} = {
b: 'bold',
code: 'code',
em: 'italic',
i: 'italic',
mark: 'highlight',
s: 'strikethrough',
strong: 'bold',
sub: 'subscript',
sup: 'superscript',
u: 'underline',
};

/**
* An extension for DecoratorTextNode that sets the format for the node and CSS classes for the DOM container.
* The base class is always set, and the focus class is set when the node is selected.
*/
export const DecoratorTextExtension = defineExtension({
name: '@lexical/extension/DecoratorText',
nodes: () => [DecoratorTextNode],
register(editor, config, state) {
return editor.registerCommand<TextFormatType>(
FORMAT_TEXT_COMMAND,
(formatType) => {
const selection = $getSelection();
if ($isNodeSelection(selection) || $isRangeSelection(selection)) {
for (const node of selection.getNodes()) {
if ($isDecoratorTextNode(node)) {
node.toggleFormat(formatType);
}
}
}

return false;
},
COMMAND_PRIORITY_LOW,
);
},
});
8 changes: 8 additions & 0 deletions packages/lexical-extension/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ export {
registerClearEditor,
} from './ClearEditorExtension';
export {getKnownTypesAndNodes, type KnownTypesAndNodes} from './config';
export {
$isDecoratorTextNode,
applyFormatFromStyle,
applyFormatToDom,
DecoratorTextExtension,
DecoratorTextNode,
type SerializedDecoratorTextNode,
} from './DecoratorTextExtension';
export {EditorStateExtension} from './EditorStateExtension';
export {getExtensionDependencyFromEditor} from './getExtensionDependencyFromEditor';
export {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1211,7 +1211,7 @@ test.describe.parallel('TextFormatting', () => {
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">A</span>
${getExpectedDateTimeHtml()}
${getExpectedDateTimeHtml({formats: ['bold']})}
<strong
class="PlaygroundEditorTheme__textBold"
data-lexical-text="true">
Expand All @@ -1224,10 +1224,12 @@ test.describe.parallel('TextFormatting', () => {
await toggleBold(page);
await assertHTML(
page,
// After formatting the text, the selection will be reset from the decorator node,
// so it will retain its previous format when toggleBold is triggered again
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">A</span>
${getExpectedDateTimeHtml()}
${getExpectedDateTimeHtml({formats: ['bold']})}
<span data-lexical-text="true">BC</span>
</p>
`,
Expand Down
17 changes: 14 additions & 3 deletions packages/lexical-playground/__tests__/utils/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -788,18 +788,29 @@ export async function insertDateTime(page) {
await sleep(500);
}

export function getExpectedDateTimeHtml({selected = false} = {}) {
export function getExpectedDateTimeHtml({selected = false, formats = []} = {}) {
const now = new Date();
const date = new Date(now.getFullYear(), now.getMonth(), now.getDate());

// DateTimeNode displays a limited set of formats
const formatToClassname = {
bold: 'bold',
highlight: 'highlight',
italic: 'italic',
strikethrough: 'strikethrough',
underline: 'underline',
};

return html`
<span
contenteditable="false"
style="display: inline-block;"
data-lexical-datetime="${date.toString()}"
data-lexical-decorator="true">
<div
class="dateTimePill ${selected ? 'selected' : ''}"
style="cursor: pointer; width: fit-content;">
class="dateTimePill ${selected ? 'selected' : ''} ${formats
.map((f) => formatToClassname[f] || '')
.join(' ')}">
${date.toDateString()}
</div>
</span>
Expand Down
2 changes: 2 additions & 0 deletions packages/lexical-playground/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
*
*/

import {DecoratorTextExtension} from '@lexical/extension';
import {$createLinkNode} from '@lexical/link';
import {$createListItemNode, $createListNode} from '@lexical/list';
import {LexicalCollaboration} from '@lexical/react/LexicalCollaborationContext';
Expand Down Expand Up @@ -133,6 +134,7 @@ function App(): JSX.Element {
: emptyEditor
? undefined
: $prepopulatedRichText,
dependencies: [DecoratorTextExtension],
html: buildHTMLConfig(),
name: '@lexical/playground',
namespace: 'Playground',
Expand Down
Loading