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
4 changes: 2 additions & 2 deletions .github/workflows/call-e2e-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,14 @@ jobs:
fail-on-cache-miss: ${{ inputs.fail-on-cache-miss }}
- name: Run tests
if: inputs.editor-mode != 'rich-text-with-collab'
run: pnpm run test-e2e-${{ inputs.prod && 'prod-' || '' }}${{ inputs.browser }} ${{ inputs.flaky && '--grep' || '--grep-invert' }} "@flaky"
run: pnpm run test-e2e-${{ inputs.prod && 'prod-' || '' }}${{ inputs.browser }} ${{ inputs.flaky && '--grep' || '--grep-invert' }} "@flaky"${{ inputs.flaky && ' --pass-with-no-tests' || '' }}
- name: Run collab tests
if: inputs.editor-mode == 'rich-text-with-collab'
shell: bash
run: |
pnpm exec concurrently -k -s first \
"pnpm run collab" \
"pnpm run test-e2e-collab-${{ inputs.prod && 'prod-' || '' }}${{ inputs.browser }} ${{ inputs.flaky && '--grep' || '--grep-invert' }} @flaky"
"pnpm run test-e2e-collab-${{ inputs.prod && 'prod-' || '' }}${{ inputs.browser }} ${{ inputs.flaky && '--grep' || '--grep-invert' }} @flaky${{ inputs.flaky && ' --pass-with-no-tests' || '' }}"
- name: Upload Artifacts
if: failure()
uses: actions/upload-artifact@v7
Expand Down
19 changes: 5 additions & 14 deletions packages/lexical-code-core/src/FlatStructureUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
$isLineBreakNode,
$isTabNode,
getTextDirection,
tokenizeRawText,
} from 'lexical';

import {
Expand Down Expand Up @@ -237,20 +238,10 @@ export function $getEndOfCodeInLine(
*/
export function $plainifyCodeContent(text: string): LexicalNode[] {
const out: LexicalNode[] = [];
const lines = text.split('\n');
lines.forEach((line, lineIdx) => {
if (lineIdx > 0) {
out.push($createLineBreakNode());
}
const tabParts = line.split('\t');
tabParts.forEach((part, partIdx) => {
if (partIdx > 0) {
out.push($createTabNode());
}
if (part.length > 0) {
out.push($createCodeHighlightNode(part));
}
});
tokenizeRawText(text, {
linebreak: () => out.push($createLineBreakNode()),
tab: () => out.push($createTabNode()),
text: part => out.push($createCodeHighlightNode(part)),
});
return out;
}
Expand Down
19 changes: 6 additions & 13 deletions packages/lexical-code-prism/src/FacadePrism.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ import 'prismjs/components/prism-java';
import 'prismjs/components/prism-cpp';

import {$createCodeHighlightNode} from '@lexical/code-core';
import {$createLineBreakNode, $createTabNode} from 'lexical';
import {$createLineBreakNode, $createTabNode, tokenizeRawText} from 'lexical';

declare global {
interface Window {
Expand Down Expand Up @@ -272,18 +272,11 @@ function $mapTokensToLexicalStructure(

for (const token of tokens) {
if (typeof token === 'string') {
const partials = token.split(/(\n|\t)/);
const partialsLength = partials.length;
for (let i = 0; i < partialsLength; i++) {
const part = partials[i];
if (part === '\n' || part === '\r\n') {
nodes.push($createLineBreakNode());
} else if (part === '\t') {
nodes.push($createTabNode());
} else if (part.length > 0) {
nodes.push($createCodeHighlightNode(part, type));
}
}
tokenizeRawText(token, {
linebreak: () => nodes.push($createLineBreakNode()),
tab: () => nodes.push($createTabNode()),
text: part => nodes.push($createCodeHighlightNode(part, type)),
});
} else {
const {content, alias} = token;
if (typeof content === 'string') {
Expand Down
25 changes: 14 additions & 11 deletions packages/lexical-code-shiki/src/FacadeShiki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,12 @@ import {
stringifyTokenStyle,
} from '@shikijs/core';
import {createJavaScriptRegexEngine} from '@shikijs/engine-javascript';
import {$createLineBreakNode, $createTabNode, $getNodeByKey} from 'lexical';
import {
$createLineBreakNode,
$createTabNode,
$getNodeByKey,
tokenizeRawText,
} from 'lexical';
import {bundledLanguagesInfo} from 'shiki/langs';
import {bundledThemesInfo} from 'shiki/themes';

Expand Down Expand Up @@ -195,19 +200,17 @@ function mapTokensToLexicalStructure(
}
}

const parts = text.split('\t');
parts.forEach((part: string, pidx: number) => {
if (pidx) {
nodes.push($createTabNode());
}
if (part !== '') {
const style = stringifyTokenStyle(
token.htmlStyle || getTokenStyleObject(token),
);
tokenizeRawText(text, {
linebreak: () => nodes.push($createLineBreakNode()),
tab: () => nodes.push($createTabNode()),
text: (part: string) => {
const node = $createCodeHighlightNode(part);
const style = stringifyTokenStyle(
token.htmlStyle || getTokenStyleObject(token),
);
node.setStyle(style);
nodes.push(node);
}
},
});
});
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* 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 {createHeadlessEditor} from '@lexical/headless';
import {$generateHtmlFromNodes} from '@lexical/html';
import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical';
import {describe, expect, test} from 'vitest';

describe('$generateHtmlFromNodes backward compatibility', () => {
test('works inside legacy editor.getEditorState().read(cb) scope (no active editor)', () => {
const editor = createHeadlessEditor({
nodes: [],
});

// Populate editor state
editor.update(
() => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode('Hello world'));
$getRoot().append(paragraph);
},
{discrete: true},
);

// Legacy pattern: editor.getEditorState().read(cb) WITHOUT {editor} option.
// Before this fix, this would throw because $setTextContent calls $getEditor()
// which requires an active editor scope.
const html = editor.getEditorState().read(() => {
return $generateHtmlFromNodes(editor);
});

expect(html).toContain('Hello world');
});

test('still works inside editor.read() scope (active editor present)', () => {
const editor = createHeadlessEditor({
nodes: [],
});

editor.update(
() => {
const paragraph = $createParagraphNode();
paragraph.append($createTextNode('Test content'));
$getRoot().append(paragraph);
},
{discrete: true},
);

const html = editor.read(() => {
return $generateHtmlFromNodes(editor);
});

expect(html).toContain('Test content');
});
});
5 changes: 5 additions & 0 deletions packages/lexical-html/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type {
import invariant from '@lexical/internal/invariant';
import {$sliceSelectedTextNodeContent} from '@lexical/selection';
import {
$assumeActiveEditor,
$createLineBreakNode,
$createParagraphNode,
$getEditor,
Expand Down Expand Up @@ -251,6 +252,10 @@ export function $generateHtmlFromNodes(
'To use $generateHtmlFromNodes in headless mode please initialize a headless browser implementation such as JSDom or use withDOM from @lexical/headless/dom before calling this function.',
);
}
// BC: $setTextContent now requires an active-editor scope (added in #8519).
// If the caller is in a legacy `editorState.read(cb)` scope (no active editor),
// establish one via internal API.
$assumeActiveEditor(editor);
return $generateDOMFromNodes(document.createElement('div'), selection, editor)
.innerHTML;
}
Expand Down
80 changes: 38 additions & 42 deletions packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,49 +28,45 @@ test.describe('Autocomplete', () => {
test.beforeEach(({isCollab, page}) =>
initialize({isAutocomplete: true, isCollab, page}),
);
test(
'Can autocomplete a word',
{tag: '@flaky'},
async ({page, isPlainText}) => {
await focusEditor(page);
await page.keyboard.type('Sort by alpha');
await sleep(500);
// The ghost is a DOM-only decoration on the active TextNode's span and
// is intentionally not part of EditorState, so it doesn't sync through
// Yjs to the right collab frame.
await assertHTML(
page,
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">
Sort by alpha
<span
class="PlaygroundEditorTheme__autocomplete"
contenteditable="false"
data-autocomplete-ghost="true">
betical (TAB)
</span>
test('Can autocomplete a word', async ({page, isPlainText}) => {
await focusEditor(page);
await page.keyboard.type('Sort by alpha');
await sleep(500);
// The ghost is a DOM-only decoration on the active TextNode's span and
// is intentionally not part of EditorState, so it doesn't sync through
// Yjs to the right collab frame.
await assertHTML(
page,
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">
Sort by alpha
<span
class="PlaygroundEditorTheme__autocomplete"
contenteditable="false"
data-autocomplete-ghost="true">
betical (TAB)
</span>
</p>
`,
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">Sort by alpha</span>
</p>
`,
);
await page.keyboard.press('Tab');
await page.keyboard.type(' order:');
await assertHTML(
page,
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">Sort by alphabetical order:</span>
</p>
`,
);
},
);
</span>
</p>
`,
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">Sort by alpha</span>
</p>
`,
);
await page.keyboard.press('Tab');
await page.keyboard.type(' order:');
await assertHTML(
page,
html`
<p class="PlaygroundEditorTheme__paragraph" dir="auto">
<span data-lexical-text="true">Sort by alphabetical order:</span>
</p>
`,
);
});

test('Can autocomplete in the same format as the original text', async ({
page,
Expand Down
Loading
Loading