Skip to content

Commit

Permalink
[playground] Feature: Playground link sharing
Browse files Browse the repository at this point in the history
  • Loading branch information
etrepum committed May 4, 2024
1 parent 3e117f8 commit a869280
Show file tree
Hide file tree
Showing 4 changed files with 181 additions and 28 deletions.
85 changes: 62 additions & 23 deletions packages/lexical-file/src/fileImportExport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Expand Down Expand Up @@ -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,
Expand All @@ -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;

Expand Down
10 changes: 7 additions & 3 deletions packages/lexical-file/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
*
*/

import {exportFile, importFile} from './fileImportExport';

export {exportFile, importFile};
export {
editorStateFromSerializedDocument,
exportFile,
importFile,
type SerializedDocument,
serializedDocumentFromEditorState,
} from './fileImportExport';
4 changes: 4 additions & 0 deletions packages/lexical-playground/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
110 changes: 108 additions & 2 deletions packages/lexical-playground/src/plugins/ActionsPlugin/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -23,6 +29,7 @@ import {
$getRoot,
$isParagraphNode,
CLEAR_EDITOR_COMMAND,
CLEAR_HISTORY_COMMAND,
COMMAND_PRIORITY_EDITOR,
} from 'lexical';
import * as React from 'react';
Expand Down Expand Up @@ -74,6 +81,80 @@ async function validateEditorState(editor: LexicalEditor): Promise<void> {
}
}

// 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('');
}

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(/=+$/, '')}`;
}

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.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<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,
}: {
Expand All @@ -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) => {
Expand Down Expand Up @@ -195,6 +283,24 @@ export default function ActionsPlugin({
aria-label="Export editor state to JSON">
<i className="export" />
</button>
<button
className="action-button share"
onClick={() =>
shareDoc(
serializedDocumentFromEditorState(editor.getEditorState(), {
source: 'Playground',
}),
).then(() => {
showModal('URL copied to clipboard', (onClose) => {
setTimeout(onClose, 1000);
return <>URL copied</>;
});
})
}
title="Share"
aria-label="Share Playground link to current editor state">
<i className="share" />
</button>
<button
className="action-button clear"
disabled={isEditorEmpty}
Expand Down

0 comments on commit a869280

Please sign in to comment.