diff --git a/packages/lexical-file/src/fileImportExport.ts b/packages/lexical-file/src/fileImportExport.ts index 0109e5ec83f..381709a6dff 100644 --- a/packages/lexical-file/src/fileImportExport.ts +++ b/packages/lexical-file/src/fileImportExport.ts @@ -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; diff --git a/packages/lexical-file/src/index.ts b/packages/lexical-file/src/index.ts index aa24b7fdb75..b35311e0c76 100644 --- a/packages/lexical-file/src/index.ts +++ b/packages/lexical-file/src/index.ts @@ -6,6 +6,10 @@ * */ -import {exportFile, importFile} from './fileImportExport'; - -export {exportFile, importFile}; +export { + editorStateFromSerializedDocument, + exportFile, + importFile, + type SerializedDocument, + serializedDocumentFromEditorState, +} from './fileImportExport'; diff --git a/packages/lexical-playground/src/index.css b/packages/lexical-playground/src/index.css index 78bbfa86ebe..9e9477e85e2 100644 --- a/packages/lexical-playground/src/index.css +++ b/packages/lexical-playground/src/index.css @@ -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); } diff --git a/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx b/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx index ac0b3863829..dcf529c72cf 100644 --- a/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx @@ -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,6 +29,7 @@ import { $getRoot, $isParagraphNode, CLEAR_EDITOR_COMMAND, + CLEAR_HISTORY_COMMAND, COMMAND_PRIORITY_EDITOR, } from 'lexical'; import * as React from 'react'; @@ -74,6 +81,80 @@ async function validateEditorState(editor: LexicalEditor): Promise { } } +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function* generateReader( + reader: ReadableStreamDefaultReader, +) { + 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 { + 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(''); +} + +async function docToHash(doc: SerializedDocument): Promise { + 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(/=+$/, '')}`; +} + +async function docFromHash(hash: string): Promise { + 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.buffer).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('')); +} + +async function shareDoc(doc: SerializedDocument): Promise { + 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, }: { @@ -86,7 +167,14 @@ export default function ActionsPlugin({ const [isEditorEmpty, setIsEditorEmpty] = useState(true); const [modal, showModal] = useModal(); const {isCollabActive} = useCollaborationContext(); - + useEffect(() => { + docFromHash(window.location.hash).then((doc) => { + if (doc) { + editor.setEditorState(editorStateFromSerializedDocument(editor, doc)); + editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined); + } + }); + }, [editor]); useEffect(() => { return mergeRegister( editor.registerEditableListener((editable) => { @@ -195,6 +283,24 @@ export default function ActionsPlugin({ aria-label="Export editor state to JSON"> +