diff --git a/src/editor/plugins/ToolbarPlugin.tsx b/src/editor/plugins/ToolbarPlugin.tsx index ffee164..ee63995 100644 --- a/src/editor/plugins/ToolbarPlugin.tsx +++ b/src/editor/plugins/ToolbarPlugin.tsx @@ -19,6 +19,7 @@ import { $getSelectionStyleValueForProperty, $patchStyleText } from '@lexical/se import { $findMatchingParent } from '@lexical/utils'; import DropdownColorPicker from '../ui/DropdownColorPicker'; +import { exportToJSON, importFromJSON } from '../utils/exportImport'; import '../styles/toolbar.css'; export default function ToolbarPlugin() { @@ -279,6 +280,22 @@ export default function ToolbarPlugin() { aria-label="Justify Align"> +
+ + + ); } diff --git a/src/editor/styles/toolbar.css b/src/editor/styles/toolbar.css index 2ed63a4..4b54899 100644 --- a/src/editor/styles/toolbar.css +++ b/src/editor/styles/toolbar.css @@ -144,3 +144,12 @@ i.format.right-align { i.format.justify-align { background-image: url('data:image/svg+xml;utf8,'); } + +i.format.import { + background-image: url('data:image/svg+xml;utf8,'); +} + +i.format.export { + background-image: url('data:image/svg+xml;utf8,'); +} + diff --git a/src/editor/utils/exportImport.ts b/src/editor/utils/exportImport.ts new file mode 100644 index 0000000..6fcff53 --- /dev/null +++ b/src/editor/utils/exportImport.ts @@ -0,0 +1,115 @@ +import type { LexicalEditor, SerializedEditorState } from 'lexical'; +import { CLEAR_HISTORY_COMMAND } from 'lexical'; + +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 Cialloo */ + source: string; +} + +/** + * Generates a SerializedDocument from the current editor state + */ +function serializedDocumentFromEditorState( + editor: LexicalEditor, + config: Readonly<{ + source?: string; + lastSaved?: number; + }> = Object.freeze({}), +): SerializedDocument { + return { + editorState: editor.getEditorState().toJSON(), + lastSaved: config.lastSaved || Date.now(), + source: config.source || 'Cialloo Editor', + }; +} + +/** + * Exports the editor content as a JSON file + */ +export function exportToJSON( + editor: LexicalEditor, + config: Readonly<{ + fileName?: string; + source?: string; + }> = Object.freeze({}), +) { + const now = new Date(); + const serializedDocument = serializedDocumentFromEditorState(editor, { + ...config, + lastSaved: now.getTime(), + }); + const fileName = config.fileName || `document-${now.toISOString()}`; + exportBlob(serializedDocument, `${fileName}.json`); +} + +/** + * Creates a downloadable blob and triggers download + */ +function exportBlob(data: SerializedDocument, fileName: string) { + const a = document.createElement('a'); + const body = document.body; + + if (body === null) { + return; + } + + body.appendChild(a); + a.style.display = 'none'; + const json = JSON.stringify(data, null, 2); // Pretty print with 2 spaces + const blob = new Blob([json], { + type: 'application/json', + }); + const url = window.URL.createObjectURL(blob); + a.href = url; + a.download = fileName; + a.click(); + window.URL.revokeObjectURL(url); + a.remove(); +} + +/** + * Imports editor content from a JSON file + */ +export function importFromJSON(editor: LexicalEditor) { + readJSONFileFromSystem((text) => { + try { + const json = JSON.parse(text) as SerializedDocument; + const editorState = editor.parseEditorState(json.editorState); + editor.setEditorState(editorState); + editor.dispatchCommand(CLEAR_HISTORY_COMMAND, undefined); + } catch (error) { + console.error('Failed to import JSON:', error); + alert('Failed to import file. Please make sure it\'s a valid JSON export.'); + } + }); +} + +/** + * Reads a JSON file from the user's system + */ +function readJSONFileFromSystem(callback: (text: string) => void) { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json'; + input.addEventListener('change', (event: Event) => { + const target = event.target as HTMLInputElement; + + if (target.files) { + const file = target.files[0]; + const reader = new FileReader(); + reader.readAsText(file, 'UTF-8'); + + reader.onload = (readerEvent) => { + if (readerEvent.target) { + const content = readerEvent.target.result; + callback(content as string); + } + }; + } + }); + input.click(); +}