mirror of
https://github.com/facebook/lexical.git
synced 2025-05-17 15:18:47 +08:00
[lexical-playground][file] Feature: Playground link sharing (#6028)
This commit is contained in:
2552
package-lock.json
generated
2552
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -128,7 +128,7 @@
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@size-limit/preset-big-lib": "^11.1.2",
|
||||
"@types/child-process-promise": "^2.2.6",
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/jsdom": "^21.1.6",
|
||||
"@types/katex": "^0.16.7",
|
||||
"@types/node": "^17.0.31",
|
||||
@ -150,7 +150,7 @@
|
||||
"eslint-plugin-ft-flow": "^3.0.7",
|
||||
"eslint-plugin-header": "^3.1.1",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"eslint-plugin-jest": "^28.4.0",
|
||||
"eslint-plugin-jest": "^28.5.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||
"eslint-plugin-lexical": "file:./eslint-plugin",
|
||||
"eslint-plugin-no-function-declare-after-return": "^1.1.0",
|
||||
@ -167,8 +167,8 @@
|
||||
"hermes-parser": "^0.20.1",
|
||||
"hermes-transform": "^0.20.1",
|
||||
"husky": "^7.0.1",
|
||||
"jest": "^29.4.0",
|
||||
"jest-environment-jsdom": "^29.4.0",
|
||||
"jest": "^29.7.0",
|
||||
"jest-environment-jsdom": "^29.7.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"lint-staged": "^11.1.0",
|
||||
"minimist": "^1.2.5",
|
||||
|
@ -6,23 +6,69 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import type {EditorState, LexicalEditor} from 'lexical';
|
||||
import type {EditorState, LexicalEditor, SerializedEditorState} from 'lexical';
|
||||
|
||||
import {CLEAR_HISTORY_COMMAND} from 'lexical';
|
||||
|
||||
import {version} from '../package.json';
|
||||
|
||||
export interface SerializedDocument {
|
||||
/** The serialized editorState produced by editorState.toJSON() */
|
||||
editorState: SerializedEditorState;
|
||||
/** The time this document was created in epoch milliseconds (Date.now()) */
|
||||
lastSaved: number;
|
||||
/** The source of the document, defaults to Lexical */
|
||||
source: string | 'Lexical';
|
||||
/** The version of Lexical that produced this document */
|
||||
version: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a SerializedDocument from the given EditorState
|
||||
* @param editorState - the EditorState to serialize
|
||||
* @param config - An object that optionally contains source and lastSaved.
|
||||
* source defaults to Lexical and lastSaved defaults to the current time in
|
||||
* epoch milliseconds.
|
||||
*/
|
||||
export function serializedDocumentFromEditorState(
|
||||
editorState: EditorState,
|
||||
config: Readonly<{
|
||||
source?: string;
|
||||
lastSaved?: number;
|
||||
}> = Object.freeze({}),
|
||||
): SerializedDocument {
|
||||
return {
|
||||
editorState: editorState.toJSON(),
|
||||
lastSaved: config.lastSaved || Date.now(),
|
||||
source: config.source || 'Lexical',
|
||||
version,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an EditorState from the given editor and document
|
||||
*
|
||||
* @param editor - The lexical editor
|
||||
* @param maybeStringifiedDocument - The contents of a .lexical file (as a JSON string, or already parsed)
|
||||
*/
|
||||
export function editorStateFromSerializedDocument(
|
||||
editor: LexicalEditor,
|
||||
maybeStringifiedDocument: SerializedDocument | string,
|
||||
): EditorState {
|
||||
const json =
|
||||
typeof maybeStringifiedDocument === 'string'
|
||||
? JSON.parse(maybeStringifiedDocument)
|
||||
: maybeStringifiedDocument;
|
||||
return editor.parseEditorState(json.editorState);
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a file and inputs its content into the editor state as an input field.
|
||||
* @param editor - The lexical editor.
|
||||
*/
|
||||
export function importFile(editor: LexicalEditor) {
|
||||
readTextFileFromSystem((text) => {
|
||||
const json = JSON.parse(text);
|
||||
const editorState = editor.parseEditorState(
|
||||
JSON.stringify(json.editorState),
|
||||
);
|
||||
editor.setEditorState(editorState);
|
||||
editor.setEditorState(editorStateFromSerializedDocument(editor, text));
|
||||
editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
|
||||
});
|
||||
}
|
||||
@ -50,18 +96,11 @@ function readTextFileFromSystem(callback: (text: string) => void) {
|
||||
input.click();
|
||||
}
|
||||
|
||||
type DocumentJSON = {
|
||||
editorState: EditorState;
|
||||
lastSaved: number;
|
||||
source: string | 'Lexical';
|
||||
version: typeof version;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a .lexical file to be downloaded by the browser containing the current editor state.
|
||||
* @param editor - The lexical editor.
|
||||
* @param config - An object that optionally contains fileName and source. fileName defaults to
|
||||
* the current date (as a string) and source defaults to lexical.
|
||||
* the current date (as a string) and source defaults to Lexical.
|
||||
*/
|
||||
export function exportFile(
|
||||
editor: LexicalEditor,
|
||||
@ -71,19 +110,19 @@ export function exportFile(
|
||||
}> = Object.freeze({}),
|
||||
) {
|
||||
const now = new Date();
|
||||
const editorState = editor.getEditorState();
|
||||
const documentJSON: DocumentJSON = {
|
||||
editorState: editorState,
|
||||
lastSaved: now.getTime(),
|
||||
source: config.source || 'Lexical',
|
||||
version,
|
||||
};
|
||||
const serializedDocument = serializedDocumentFromEditorState(
|
||||
editor.getEditorState(),
|
||||
{
|
||||
...config,
|
||||
lastSaved: now.getTime(),
|
||||
},
|
||||
);
|
||||
const fileName = config.fileName || now.toISOString();
|
||||
exportBlob(documentJSON, `${fileName}.lexical`);
|
||||
exportBlob(serializedDocument, `${fileName}.lexical`);
|
||||
}
|
||||
|
||||
// Adapted from https://stackoverflow.com/a/19328891/2013580
|
||||
function exportBlob(data: DocumentJSON, fileName: string) {
|
||||
function exportBlob(data: SerializedDocument, fileName: string) {
|
||||
const a = document.createElement('a');
|
||||
const body = document.body;
|
||||
|
||||
|
@ -6,6 +6,10 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import {exportFile, importFile} from './fileImportExport';
|
||||
|
||||
export {exportFile, importFile};
|
||||
export {
|
||||
editorStateFromSerializedDocument,
|
||||
exportFile,
|
||||
importFile,
|
||||
type SerializedDocument,
|
||||
serializedDocumentFromEditorState,
|
||||
} from './fileImportExport';
|
||||
|
84
packages/lexical-playground/__tests__/e2e/Share.spec.mjs
Normal file
84
packages/lexical-playground/__tests__/e2e/Share.spec.mjs
Normal file
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 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,
|
||||
click,
|
||||
expect,
|
||||
focusEditor,
|
||||
getPageOrFrame,
|
||||
html,
|
||||
initialize,
|
||||
test,
|
||||
} from '../utils/index.mjs';
|
||||
|
||||
test.use({
|
||||
launchOptions: {
|
||||
firefoxUserPrefs: {
|
||||
'dom.events.asyncClipboard.readText': true,
|
||||
'dom.events.testing.asyncClipboard': true,
|
||||
},
|
||||
},
|
||||
});
|
||||
test.describe('Share', () => {
|
||||
test.beforeEach(({isCollab, page}) => initialize({isCollab, page}));
|
||||
test('is disabled in collab', async ({page, isCollab}) => {
|
||||
test.skip(!isCollab);
|
||||
const leftFrame = getPageOrFrame(page);
|
||||
await expect(leftFrame.locator('.action-button.share')).toBeDisabled();
|
||||
});
|
||||
test('can share the editor state', async ({page, isCollab, browserName}) => {
|
||||
test.skip(isCollab);
|
||||
|
||||
await focusEditor(page);
|
||||
const fooHTML = html`
|
||||
<p
|
||||
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
|
||||
dir="ltr">
|
||||
<span data-lexical-text="true">foo</span>
|
||||
</p>
|
||||
`;
|
||||
await page.keyboard.type('foo');
|
||||
await assertHTML(page, fooHTML);
|
||||
|
||||
if (browserName === 'chromium') {
|
||||
await page
|
||||
.context()
|
||||
.grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
}
|
||||
expect(page.url()).not.toMatch(/#doc=/);
|
||||
await click(page, '.action-button.share');
|
||||
await page.getByRole('alert').getByText('URL copied to clipboard');
|
||||
const fooUrl = page.url();
|
||||
expect(fooUrl).toMatch(/#doc=/);
|
||||
if (browserName !== 'webkit') {
|
||||
expect(await page.evaluate('navigator.clipboard.readText()')).toEqual(
|
||||
fooUrl,
|
||||
);
|
||||
}
|
||||
if (browserName === 'chromium') {
|
||||
await page.context().clearPermissions();
|
||||
}
|
||||
await focusEditor(page);
|
||||
await page.keyboard.type('bar');
|
||||
await assertHTML(
|
||||
page,
|
||||
html`
|
||||
<p
|
||||
class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr"
|
||||
dir="ltr">
|
||||
<span data-lexical-text="true">foobar</span>
|
||||
</p>
|
||||
`,
|
||||
);
|
||||
// The URL also changed so we can just reload to get the copied state
|
||||
await page.reload();
|
||||
await focusEditor(page);
|
||||
await assertHTML(page, fooHTML);
|
||||
});
|
||||
});
|
@ -0,0 +1,52 @@
|
||||
/**
|
||||
* @jest-environment <rootDir>/packages/lexical-playground/__tests__/unit/jsdom-with-compression-environment
|
||||
*/
|
||||
|
||||
// Jest environment should be at the very top of the file. overriding environment for this test
|
||||
// because jest-environment-jsdom does not have compression APIs
|
||||
|
||||
/* eslint-disable header/header */
|
||||
/**
|
||||
* 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 {serializedDocumentFromEditorState} from '@lexical/file';
|
||||
import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical';
|
||||
import {initializeUnitTest} from 'lexical/src/__tests__/utils';
|
||||
|
||||
import {docFromHash, docToHash} from '../../src/utils/docSerialization';
|
||||
|
||||
describe('docSerialization', () => {
|
||||
initializeUnitTest((testEnv) => {
|
||||
describe('docToHash/docFromHash round-trips', () => {
|
||||
it('with empty state', async () => {
|
||||
const {editor} = testEnv;
|
||||
const emptyState = editor.getEditorState();
|
||||
const doc = serializedDocumentFromEditorState(emptyState, {
|
||||
source: 'Playground',
|
||||
});
|
||||
expect(await docFromHash(await docToHash(doc))).toEqual(doc);
|
||||
});
|
||||
it('with some state', async () => {
|
||||
const {editor} = testEnv;
|
||||
editor.update(
|
||||
() => {
|
||||
const p = $createParagraphNode();
|
||||
p.append($createTextNode(`It's alive!`));
|
||||
$getRoot().append($createParagraphNode());
|
||||
},
|
||||
{discrete: true},
|
||||
);
|
||||
const hasState = editor.getEditorState();
|
||||
const doc = serializedDocumentFromEditorState(hasState, {
|
||||
source: 'Playground',
|
||||
});
|
||||
expect(await docFromHash(await docToHash(doc))).toEqual(doc);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 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 {TestEnvironment} from 'jest-environment-jsdom';
|
||||
|
||||
export default class JSDomWithCompressionEnvironment extends TestEnvironment {
|
||||
async setup() {
|
||||
await super.setup();
|
||||
Object.assign(this.global, {
|
||||
CompressionStream,
|
||||
DecompressionStream,
|
||||
TextDecoderStream,
|
||||
TextEncoder,
|
||||
});
|
||||
}
|
||||
}
|
@ -14,6 +14,7 @@ import {$createParagraphNode, $createTextNode, $getRoot} from 'lexical';
|
||||
import * as React from 'react';
|
||||
|
||||
import {isDevPlayground} from './appSettings';
|
||||
import {FlashMessageContext} from './context/FlashMessageContext';
|
||||
import {SettingsContext, useSettings} from './context/SettingsContext';
|
||||
import {SharedAutocompleteContext} from './context/SharedAutocompleteContext';
|
||||
import {SharedHistoryContext} from './context/SharedHistoryContext';
|
||||
@ -160,7 +161,9 @@ function App(): JSX.Element {
|
||||
export default function PlaygroundApp(): JSX.Element {
|
||||
return (
|
||||
<SettingsContext>
|
||||
<App />
|
||||
<FlashMessageContext>
|
||||
<App />
|
||||
</FlashMessageContext>
|
||||
<a
|
||||
href="https://github.com/facebook/lexical/tree/main/packages/lexical-playground"
|
||||
className="github-corner"
|
||||
|
@ -0,0 +1,68 @@
|
||||
/**
|
||||
* 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 {
|
||||
createContext,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import FlashMessage from '../ui/FlashMessage';
|
||||
|
||||
export type ShowFlashMessage = (
|
||||
message?: React.ReactNode,
|
||||
duration?: number,
|
||||
) => void;
|
||||
|
||||
interface FlashMessageProps {
|
||||
message?: React.ReactNode;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
const Context = createContext<ShowFlashMessage | undefined>(undefined);
|
||||
const INITIAL_STATE: FlashMessageProps = {};
|
||||
const DEFAULT_DURATION = 1000;
|
||||
|
||||
export const FlashMessageContext = ({
|
||||
children,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
}): JSX.Element => {
|
||||
const [props, setProps] = useState(INITIAL_STATE);
|
||||
const showFlashMessage = useCallback<ShowFlashMessage>(
|
||||
(message, duration) =>
|
||||
setProps(message ? {duration, message} : INITIAL_STATE),
|
||||
[],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (props.message) {
|
||||
const timeoutId = setTimeout(
|
||||
() => setProps(INITIAL_STATE),
|
||||
props.duration ?? DEFAULT_DURATION,
|
||||
);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [props]);
|
||||
return (
|
||||
<Context.Provider value={showFlashMessage}>
|
||||
{children}
|
||||
{props.message && <FlashMessage>{props.message}</FlashMessage>}
|
||||
</Context.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useFlashMessageContext = (): ShowFlashMessage => {
|
||||
const ctx = useContext(Context);
|
||||
if (!ctx) {
|
||||
throw new Error('Missing FlashMessageContext');
|
||||
}
|
||||
return ctx;
|
||||
};
|
16
packages/lexical-playground/src/hooks/useFlashMessage.tsx
Normal file
16
packages/lexical-playground/src/hooks/useFlashMessage.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
/**
|
||||
* 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 ShowFlashMessage,
|
||||
useFlashMessageContext,
|
||||
} from '../context/FlashMessageContext';
|
||||
|
||||
export default function useFlashMessage(): ShowFlashMessage {
|
||||
return useFlashMessageContext();
|
||||
}
|
@ -536,6 +536,10 @@ i.export {
|
||||
background-image: url(images/icons/download.svg);
|
||||
}
|
||||
|
||||
i.share {
|
||||
background-image: url(images/icons/send.svg);
|
||||
}
|
||||
|
||||
i.diagram-2 {
|
||||
background-image: url(images/icons/diagram-2.svg);
|
||||
}
|
||||
|
@ -9,7 +9,13 @@
|
||||
import type {LexicalEditor} from 'lexical';
|
||||
|
||||
import {$createCodeNode, $isCodeNode} from '@lexical/code';
|
||||
import {exportFile, importFile} from '@lexical/file';
|
||||
import {
|
||||
editorStateFromSerializedDocument,
|
||||
exportFile,
|
||||
importFile,
|
||||
SerializedDocument,
|
||||
serializedDocumentFromEditorState,
|
||||
} from '@lexical/file';
|
||||
import {
|
||||
$convertFromMarkdownString,
|
||||
$convertToMarkdownString,
|
||||
@ -23,13 +29,16 @@ import {
|
||||
$getRoot,
|
||||
$isParagraphNode,
|
||||
CLEAR_EDITOR_COMMAND,
|
||||
CLEAR_HISTORY_COMMAND,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
} from 'lexical';
|
||||
import * as React from 'react';
|
||||
import {useCallback, useEffect, useState} from 'react';
|
||||
|
||||
import {INITIAL_SETTINGS} from '../../appSettings';
|
||||
import useFlashMessage from '../../hooks/useFlashMessage';
|
||||
import useModal from '../../hooks/useModal';
|
||||
import Button from '../../ui/Button';
|
||||
import {docFromHash, docToHash} from '../../utils/docSerialization';
|
||||
import {PLAYGROUND_TRANSFORMERS} from '../MarkdownTransformers';
|
||||
import {
|
||||
SPEECH_TO_TEXT_COMMAND,
|
||||
@ -74,6 +83,14 @@ async function validateEditorState(editor: LexicalEditor): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function shareDoc(doc: SerializedDocument): Promise<void> {
|
||||
const url = new URL(window.location.toString());
|
||||
url.hash = await docToHash(doc);
|
||||
const newUrl = url.toString();
|
||||
window.history.replaceState({}, '', newUrl);
|
||||
await window.navigator.clipboard.writeText(newUrl);
|
||||
}
|
||||
|
||||
export default function ActionsPlugin({
|
||||
isRichText,
|
||||
}: {
|
||||
@ -85,8 +102,19 @@ export default function ActionsPlugin({
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [isEditorEmpty, setIsEditorEmpty] = useState(true);
|
||||
const [modal, showModal] = useModal();
|
||||
const showFlashMessage = useFlashMessage();
|
||||
const {isCollabActive} = useCollaborationContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (INITIAL_SETTINGS.isCollab) {
|
||||
return;
|
||||
}
|
||||
docFromHash(window.location.hash).then((doc) => {
|
||||
if (doc && doc.source === 'Playground') {
|
||||
editor.setEditorState(editorStateFromSerializedDocument(editor, doc));
|
||||
editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined);
|
||||
}
|
||||
});
|
||||
}, [editor]);
|
||||
useEffect(() => {
|
||||
return mergeRegister(
|
||||
editor.registerEditableListener((editable) => {
|
||||
@ -195,6 +223,23 @@ export default function ActionsPlugin({
|
||||
aria-label="Export editor state to JSON">
|
||||
<i className="export" />
|
||||
</button>
|
||||
<button
|
||||
className="action-button share"
|
||||
disabled={isCollabActive || INITIAL_SETTINGS.isCollab}
|
||||
onClick={() =>
|
||||
shareDoc(
|
||||
serializedDocumentFromEditorState(editor.getEditorState(), {
|
||||
source: 'Playground',
|
||||
}),
|
||||
).then(
|
||||
() => showFlashMessage('URL copied to clipboard'),
|
||||
() => showFlashMessage('URL could not be copied to clipboard'),
|
||||
)
|
||||
}
|
||||
title="Share"
|
||||
aria-label="Share Playground link to current editor state">
|
||||
<i className="share" />
|
||||
</button>
|
||||
<button
|
||||
className="action-button clear"
|
||||
disabled={isEditorEmpty}
|
||||
|
28
packages/lexical-playground/src/ui/FlashMessage.css
Normal file
28
packages/lexical-playground/src/ui/FlashMessage.css
Normal file
@ -0,0 +1,28 @@
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
.FlashMessage__overlay {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
left: 0px;
|
||||
right: 0px;
|
||||
}
|
||||
.FlashMessage__alert {
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
font-size: 1.5rem;
|
||||
border-radius: 1em;
|
||||
padding: 0.5em 1.5em;
|
||||
}
|
29
packages/lexical-playground/src/ui/FlashMessage.tsx
Normal file
29
packages/lexical-playground/src/ui/FlashMessage.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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 './FlashMessage.css';
|
||||
|
||||
import {ReactNode} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
|
||||
export interface FlashMessageProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function FlashMessage({
|
||||
children,
|
||||
}: FlashMessageProps): JSX.Element {
|
||||
return createPortal(
|
||||
<div className="FlashMessage__overlay" role="dialog">
|
||||
<p className="FlashMessage__alert" role="alert">
|
||||
{children}
|
||||
</p>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
77
packages/lexical-playground/src/utils/docSerialization.ts
Normal file
77
packages/lexical-playground/src/utils/docSerialization.ts
Normal file
@ -0,0 +1,77 @@
|
||||
/**
|
||||
* 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 {SerializedDocument} from '@lexical/file';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function* generateReader<T = any>(
|
||||
reader: ReadableStreamDefaultReader<T>,
|
||||
) {
|
||||
let done = false;
|
||||
while (!done) {
|
||||
const res = await reader.read();
|
||||
const {value} = res;
|
||||
if (value !== undefined) {
|
||||
yield value;
|
||||
}
|
||||
done = res.done;
|
||||
}
|
||||
}
|
||||
|
||||
async function readBytestoString(
|
||||
reader: ReadableStreamDefaultReader,
|
||||
): Promise<string> {
|
||||
const output = [];
|
||||
const chunkSize = 0x8000;
|
||||
for await (const value of generateReader(reader)) {
|
||||
for (let i = 0; i < value.length; i += chunkSize) {
|
||||
output.push(String.fromCharCode(...value.subarray(i, i + chunkSize)));
|
||||
}
|
||||
}
|
||||
return output.join('');
|
||||
}
|
||||
|
||||
export async function docToHash(doc: SerializedDocument): Promise<string> {
|
||||
const cs = new CompressionStream('gzip');
|
||||
const writer = cs.writable.getWriter();
|
||||
const [, output] = await Promise.all([
|
||||
writer
|
||||
.write(new TextEncoder().encode(JSON.stringify(doc)))
|
||||
.then(() => writer.close()),
|
||||
readBytestoString(cs.readable.getReader()),
|
||||
]);
|
||||
return `#doc=${btoa(output)
|
||||
.replace(/\//g, '_')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/=+$/, '')}`;
|
||||
}
|
||||
|
||||
export async function docFromHash(
|
||||
hash: string,
|
||||
): Promise<SerializedDocument | null> {
|
||||
const m = /^#doc=(.*)$/.exec(hash);
|
||||
if (!m) {
|
||||
return null;
|
||||
}
|
||||
const ds = new DecompressionStream('gzip');
|
||||
const writer = ds.writable.getWriter();
|
||||
const b64 = atob(m[1].replace(/_/g, '/').replace(/-/g, '+'));
|
||||
const array = new Uint8Array(b64.length);
|
||||
for (let i = 0; i < b64.length; i++) {
|
||||
array[i] = b64.charCodeAt(i);
|
||||
}
|
||||
const closed = writer.write(array).then(() => writer.close());
|
||||
const output = [];
|
||||
for await (const chunk of generateReader(
|
||||
ds.readable.pipeThrough(new TextDecoderStream()).getReader(),
|
||||
)) {
|
||||
output.push(chunk);
|
||||
}
|
||||
await closed;
|
||||
return JSON.parse(output.join(''));
|
||||
}
|
@ -32,6 +32,7 @@ const config = {
|
||||
},
|
||||
],
|
||||
retries: IS_CI ? 4 : 1,
|
||||
testIgnore: /\/__tests__\/unit\//,
|
||||
timeout: 150000,
|
||||
use: {
|
||||
navigationTimeout: 30000,
|
||||
|
Reference in New Issue
Block a user