@inshapardaz/likhari-react 0.1.7-dev.81 → 0.1.7-dev.86

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/dist/EditorRoot.d.ts +57 -0
  2. package/dist/EditorRoot.d.ts.map +1 -1
  3. package/dist/EditorRoot.js +174 -11
  4. package/dist/components/DraftRestore.d.ts +24 -0
  5. package/dist/components/DraftRestore.d.ts.map +1 -0
  6. package/dist/components/DraftRestore.js +61 -0
  7. package/dist/components/DraftsDialog.d.ts +25 -0
  8. package/dist/components/DraftsDialog.d.ts.map +1 -0
  9. package/dist/components/DraftsDialog.js +66 -0
  10. package/dist/components/LeaveDialog.d.ts +22 -0
  11. package/dist/components/LeaveDialog.d.ts.map +1 -0
  12. package/dist/components/LeaveDialog.js +15 -0
  13. package/dist/components/Toolbar.d.ts +4 -1
  14. package/dist/components/Toolbar.d.ts.map +1 -1
  15. package/dist/components/Toolbar.js +8 -6
  16. package/dist/i18n/strings.d.ts +38 -5
  17. package/dist/i18n/strings.d.ts.map +1 -1
  18. package/dist/i18n/strings.js +96 -9
  19. package/dist/index.d.ts +2 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/nodes.d.ts.map +1 -1
  22. package/dist/nodes.js +0 -2
  23. package/dist/persistence/draftStorage.d.ts +55 -0
  24. package/dist/persistence/draftStorage.d.ts.map +1 -0
  25. package/dist/persistence/draftStorage.js +198 -0
  26. package/dist/persistence/draftUi.d.ts +9 -0
  27. package/dist/persistence/draftUi.d.ts.map +1 -0
  28. package/dist/persistence/draftUi.js +23 -0
  29. package/dist/theme/editor.css +74 -39
  30. package/dist/theme/editorTheme.d.ts.map +1 -1
  31. package/dist/theme/editorTheme.js +0 -1
  32. package/package.json +1 -1
  33. package/dist/blocks/PageBreakNode.d.ts +0 -30
  34. package/dist/blocks/PageBreakNode.d.ts.map +0 -1
  35. package/dist/blocks/PageBreakNode.js +0 -110
  36. package/dist/plugins/PageBreakPlugin.d.ts +0 -3
  37. package/dist/plugins/PageBreakPlugin.d.ts.map +0 -1
  38. package/dist/plugins/PageBreakPlugin.js +0 -19
@@ -3,7 +3,10 @@ import type { SerializedEditorState } from 'lexical';
3
3
  import { type FormatId } from '@inshapardaz/likhari-converters';
4
4
  import { type EditorFeatureConfig, type FeatureConfigPresetName } from '@inshapardaz/likhari-core';
5
5
  import { type FontOption } from './fonts/index.js';
6
+ import { type DraftRestoreMode } from './components/DraftRestore.js';
6
7
  import { type Locale } from './i18n/index.js';
8
+ /** What happens when the user is about to leave with unsaved changes. */
9
+ export type NavigationGuardMode = 'confirm' | 'save-draft' | 'off';
7
10
  export interface EditorInitialContent {
8
11
  format: Extract<FormatId, 'lexical-json' | 'plain-text'>;
9
12
  value: string;
@@ -70,6 +73,60 @@ export interface EditorRootProps {
70
73
  * `[...DEFAULT_FONT_OPTIONS, { name: 'Mine', family: '"Mine", serif' }]`.
71
74
  */
72
75
  fontOptions?: FontOption[];
76
+ /**
77
+ * Saves drafts: a debounced autosave of the current content to
78
+ * `localStorage` (lexical-editor-spec.md §6.2), a resilience net against an
79
+ * accidental tab close that is independent of `onSave`/the Save button. The
80
+ * draft is stored under `documentId` when you give one (§6.1); without one, a
81
+ * unique id is generated for this editor, so editors never overwrite each
82
+ * other's drafts. The toolbar's Drafts button lists every draft in the browser
83
+ * to restore or delete. A draft is cleared on a successful explicit save.
84
+ * Defaults to `true`.
85
+ */
86
+ autosave?: boolean;
87
+ /**
88
+ * What to do on mount when a draft of this `documentId` newer than the initial
89
+ * content exists (needs `documentId`):
90
+ * - `'prompt'` (default): a banner with Restore / Ignore / Remove draft;
91
+ * - `'auto'`: load the draft straight away, no questions (see `onDraftRestored`);
92
+ * - `'off'`: keep the initial content; the draft is moved aside into the drafts
93
+ * list so new edits don't overwrite it unseen.
94
+ */
95
+ restoreDraft?: DraftRestoreMode;
96
+ /**
97
+ * What happens when the user is about to leave with unsaved changes, for both
98
+ * `EditorRef.confirmDiscard()` (in-app navigation, which your router guard
99
+ * calls) and closing or refreshing the tab:
100
+ * - `'confirm'` (default): `confirmDiscard()` opens an in-editor popup (Save /
101
+ * Save draft / Discard changes / Stay); closing the tab shows the browser's
102
+ * own prompt, which can't be customised;
103
+ * - `'save-draft'`: silently saves a draft and lets the user go — no popup, no
104
+ * browser prompt. Works even with `autosave={false}`. If the draft can't be
105
+ * stored (e.g. the document is over `autosaveMaxBytes`) it falls back to
106
+ * `'confirm'`, so work is never lost silently;
107
+ * - `'off'`: no guard; the user is never asked.
108
+ */
109
+ navigationGuard?: NavigationGuardMode;
110
+ /** Called when a saved draft was loaded into the editor (banner Restore, or `restoreDraft="auto"`). */
111
+ onDraftRestored?: (draft: {
112
+ documentId: string;
113
+ savedAt: number;
114
+ }) => void;
115
+ /** Debounce delay before writing an autosave draft, in ms. Default `750`. */
116
+ autosaveDelayMs?: number;
117
+ /**
118
+ * Skips (rather than throwing) an autosave write whose JSON would exceed
119
+ * this size — `localStorage` has a shared ~5-10MB per-origin ceiling, so a
120
+ * single document with embedded base64 images must not be allowed to
121
+ * autosave past a sane limit. Default `2_000_000` (~2MB).
122
+ */
123
+ autosaveMaxBytes?: number;
124
+ /**
125
+ * How many drafts to keep in the browser, across all documents; the oldest
126
+ * beyond this are deleted as new ones are written (this editor's own draft
127
+ * is never pruned). Default `20`.
128
+ */
129
+ autosaveMaxDrafts?: number;
73
130
  }
74
131
  export interface EditorRef {
75
132
  getContent(format: FormatId): string;
@@ -1 +1 @@
1
- {"version":3,"file":"EditorRoot.d.ts","sourceRoot":"","sources":["../src/EditorRoot.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,eAAe,CAAC;AAU1D,OAAO,KAAK,EAAe,qBAAqB,EAAE,MAAM,SAAS,CAAC;AAClE,OAAO,EAAyB,KAAK,QAAQ,EAAE,MAAM,iCAAiC,CAAC;AACvF,OAAO,EAAwB,KAAK,mBAAmB,EAAE,KAAK,uBAAuB,EAAE,MAAM,2BAA2B,CAAC;AAKzH,OAAO,EAAyB,KAAK,UAAU,EAAE,MAAM,SAAS,CAAC;AAKjE,OAAO,EAAgC,KAAK,MAAM,EAAE,MAAM,QAAQ,CAAC;AAEnE,MAAM,WAAW,oBAAoB;IACnC,MAAM,EAAE,OAAO,CAAC,QAAQ,EAAE,cAAc,GAAG,YAAY,CAAC,CAAC;IACzD,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,oBAAoB,CAAC;IACtC,aAAa,CAAC,EAAE,mBAAmB,CAAC;IACpC,aAAa,CAAC,EAAE,uBAAuB,CAAC;IACxC,KAAK,CAAC,EAAE,oBAAoB,CAAC;IAC7B,WAAW,CAAC,EAAE,OAAO,GAAG,MAAM,CAAC;IAC/B;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,qBAAqB,KAAK,IAAI,CAAC;IAClD;;;OAGG;IACH,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,KAAK,IAAI,CAAC;IACrD;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,aAAa,CAAC,EAAE,CAAC,IAAI,EAAE,IAAI,KAAK,OAAO,CAAC,MAAM,CAAC,CAAC;IAChD;;;;;;;OAOG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAC5C;;;;;;OAMG;IACH,WAAW,CAAC,EAAE,UAAU,EAAE,CAAC;CAC5B;AAED,MAAM,WAAW,SAAS;IACxB,UAAU,CAAC,MAAM,EAAE,QAAQ,GAAG,MAAM,CAAC;IACrC,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,QAAQ,GAAG,IAAI,CAAC;IAClD,iBAAiB,IAAI,OAAO,CAAC;IAC7B,cAAc,IAAI,OAAO,CAAC,OAAO,CAAC,CAAC;IACnC,KAAK,IAAI,IAAI,CAAC;CACf;AAQD,eAAO,MAAM,UAAU,uGAmKrB,CAAC"}
1
+ {"version":3,"file":"EditorRoot.d.ts","sourceRoot":"","sources":["../src/EditorRoot.tsx"],"names":[],"mappings":"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"}
@@ -17,20 +17,31 @@ import { EditorThemeProvider } from './theme/EditorThemeProvider.js';
17
17
  import { Toolbar } from './components/Toolbar.js';
18
18
  import { injectUrduWebFontsCss } from './fonts/index.js';
19
19
  import { LinkPastePlugin } from './plugins/LinkPastePlugin.js';
20
- import { PageBreakPlugin } from './plugins/PageBreakPlugin.js';
20
+ import { DraftRestore } from './components/DraftRestore.js';
21
+ import { LeaveDialog } from './components/LeaveDialog.js';
22
+ import { clearDraft, createDraftId, hasDraftContent, pruneDrafts, writeDraft, } from './persistence/draftStorage.js';
21
23
  import { ImageOptionsContext } from './image/ImageOptionsContext.js';
22
24
  import { PortalTargetContext } from './PortalTargetContext.js';
23
25
  import { UiStringsContext, getStrings } from './i18n/index.js';
26
+ const DEFAULT_AUTOSAVE_DELAY_MS = 750;
27
+ const DEFAULT_AUTOSAVE_MAX_BYTES = 2000000;
28
+ const DEFAULT_AUTOSAVE_MAX_DRAFTS = 20;
24
29
  function initialEditorStateJson(initialContent) {
25
30
  if (!initialContent)
26
31
  return undefined;
27
32
  const state = defaultFormatRegistry.parse(initialContent.format, initialContent.value);
28
33
  return JSON.stringify(state);
29
34
  }
30
- export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialContent, featureConfig, featurePreset, theme, colorScheme, accentColor, locale = 'en', placeholder, height = '480px', onChange, onSave, showSave = Boolean(onSave), onImageUpload, fetchImage, fontOptions, }, ref) {
35
+ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialContent, featureConfig, featurePreset, theme, colorScheme, accentColor, locale = 'en', placeholder, height = '480px', onChange, onSave, showSave = Boolean(onSave), onImageUpload, fetchImage, fontOptions, autosave = true, restoreDraft = 'prompt', navigationGuard = 'confirm', onDraftRestored, autosaveDelayMs = DEFAULT_AUTOSAVE_DELAY_MS, autosaveMaxBytes = DEFAULT_AUTOSAVE_MAX_BYTES, autosaveMaxDrafts = DEFAULT_AUTOSAVE_MAX_DRAFTS, }, ref) {
31
36
  const config = useMemo(() => resolveFeatureConfig(featureConfig, featurePreset), [featureConfig, featurePreset]);
32
37
  const strings = useMemo(() => getStrings(locale), [locale]);
33
38
  const resolvedPlaceholder = placeholder ?? strings.editor.placeholder;
39
+ // The draft's storage key: the host's documentId, else — so editors without
40
+ // one never overwrite each other — a unique id generated for this editor (or
41
+ // the id of an anonymous draft the user restored into it).
42
+ const [generatedDraftId] = useState(createDraftId);
43
+ const [adoptedDraftId, setAdoptedDraftId] = useState(null);
44
+ const draftId = documentId ?? adoptedDraftId ?? generatedDraftId;
34
45
  // The urdu-web-fonts stylesheets are needed for the font dropdown and for
35
46
  // RTL content, whose canvas font (editor.css) is one of those families.
36
47
  useEffect(() => {
@@ -48,17 +59,129 @@ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialCo
48
59
  const editorStateRef = useRef(null);
49
60
  const lastSavedJsonRef = useRef(null);
50
61
  const [isDirty, setIsDirty] = useState(false);
62
+ const isDirtyRef = useRef(isDirty);
63
+ isDirtyRef.current = isDirty;
64
+ const autosaveTimerRef = useRef(null);
65
+ // The latest change not yet written, so unload / unmount can flush it.
66
+ const pendingDraftRef = useRef(null);
67
+ const flushDraft = () => {
68
+ if (autosaveTimerRef.current) {
69
+ clearTimeout(autosaveTimerRef.current);
70
+ autosaveTimerRef.current = null;
71
+ }
72
+ const pending = pendingDraftRef.current;
73
+ pendingDraftRef.current = null;
74
+ if (!pending)
75
+ return;
76
+ // A document that is blank again isn't worth a draft.
77
+ if (hasDraftContent(pending.json)) {
78
+ writeDraft(pending.id, pending.json, autosaveMaxBytes);
79
+ pruneDrafts(autosaveMaxDrafts, [pending.id]);
80
+ }
81
+ else {
82
+ clearDraft(pending.id);
83
+ }
84
+ };
85
+ const flushDraftRef = useRef(flushDraft);
86
+ flushDraftRef.current = flushDraft;
87
+ /** Writes the latest content as a draft now, without waiting out the debounce.
88
+ * Returns whether it is safely stored (or there was nothing worth storing). */
89
+ const saveDraftNow = () => {
90
+ if (autosaveTimerRef.current) {
91
+ clearTimeout(autosaveTimerRef.current);
92
+ autosaveTimerRef.current = null;
93
+ }
94
+ const latest = editorStateRef.current ? JSON.stringify(editorStateRef.current.toJSON()) : pendingDraftRef.current?.json;
95
+ pendingDraftRef.current = null;
96
+ if (!latest)
97
+ return true;
98
+ // A blank document isn't worth a draft.
99
+ if (!hasDraftContent(latest)) {
100
+ clearDraft(draftId);
101
+ return true;
102
+ }
103
+ const stored = writeDraft(draftId, latest, autosaveMaxBytes);
104
+ if (stored)
105
+ pruneDrafts(autosaveMaxDrafts, [draftId]);
106
+ return stored;
107
+ };
108
+ const saveDraftNowRef = useRef(saveDraftNow);
109
+ saveDraftNowRef.current = saveDraftNow;
110
+ const navigationGuardRef = useRef(navigationGuard);
111
+ navigationGuardRef.current = navigationGuard;
112
+ // The in-editor "leave?" popup behind confirmDiscard().
113
+ const [leavePrompt, setLeavePrompt] = useState(null);
114
+ const leaveResolveRef = useRef(null);
115
+ const settleLeave = (canLeave) => {
116
+ const resolve = leaveResolveRef.current;
117
+ leaveResolveRef.current = null;
118
+ setLeavePrompt(null);
119
+ resolve?.(canLeave);
120
+ };
121
+ const askToLeave = (draftFailed = false) => new Promise((resolve) => {
122
+ leaveResolveRef.current?.(false); // a second call supersedes an unanswered one
123
+ leaveResolveRef.current = resolve;
124
+ setLeavePrompt({ draftFailed });
125
+ });
126
+ const confirmDiscardImpl = async () => {
127
+ if (!isDirtyRef.current || navigationGuard === 'off')
128
+ return true;
129
+ if (navigationGuard === 'save-draft') {
130
+ if (saveDraftNow())
131
+ return true;
132
+ return askToLeave(true);
133
+ }
134
+ return askToLeave();
135
+ };
136
+ const confirmDiscardRef = useRef(confirmDiscardImpl);
137
+ confirmDiscardRef.current = confirmDiscardImpl;
138
+ // An unanswered popup must not leave a caller's promise hanging if the editor goes away.
139
+ useEffect(() => () => leaveResolveRef.current?.(false), []);
140
+ // Computed once, before the first autosave write could possibly run, so
141
+ // the restore-prompt plugin below can tell a stored draft that's merely
142
+ // identical to the initial content apart (no prompt needed) from one that
143
+ // actually represents newer, unsaved work.
144
+ const initialContentJsonRef = useRef(undefined);
145
+ if (initialContentJsonRef.current === undefined) {
146
+ initialContentJsonRef.current = initialEditorStateJson(initialContent);
147
+ }
51
148
  const initialConfig = useMemo(() => ({
52
149
  namespace: `likhari-editor-${documentId ?? 'anonymous'}`,
53
150
  nodes: EDITOR_NODES,
54
151
  theme: editorTheme,
55
- editorState: initialEditorStateJson(initialContent),
152
+ editorState: initialContentJsonRef.current,
56
153
  onError(error) {
57
154
  throw error;
58
155
  },
59
156
  }),
60
157
  // eslint-disable-next-line react-hooks/exhaustive-deps
61
158
  []);
159
+ // beforeunload (lexical-editor-spec.md §6.4): the automatic half of the
160
+ // navigation guard — hasUnsavedChanges()/confirmDiscard() below are the
161
+ // imperative half a host's own router guard calls for in-app navigation,
162
+ // which the editor can't intercept on its own.
163
+ useEffect(() => {
164
+ if (typeof window === 'undefined')
165
+ return;
166
+ const handleBeforeUnload = (event) => {
167
+ // Write any edit still waiting out the debounce before the page goes.
168
+ flushDraftRef.current();
169
+ if (!isDirtyRef.current)
170
+ return;
171
+ const guard = navigationGuardRef.current;
172
+ if (guard === 'off')
173
+ return;
174
+ // Saved a draft instead: nothing is lost, so don't interrupt the user.
175
+ if (guard === 'save-draft' && saveDraftNowRef.current())
176
+ return;
177
+ event.preventDefault();
178
+ event.returnValue = '';
179
+ };
180
+ window.addEventListener('beforeunload', handleBeforeUnload);
181
+ return () => window.removeEventListener('beforeunload', handleBeforeUnload);
182
+ }, []);
183
+ // Unmounting (e.g. in-app navigation) also writes what is still pending.
184
+ useEffect(() => () => flushDraftRef.current(), []);
62
185
  useImperativeHandle(ref, () => ({
63
186
  getContent(format) {
64
187
  if (!editorStateRef.current)
@@ -78,23 +201,35 @@ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialCo
78
201
  hasUnsavedChanges() {
79
202
  return isDirty;
80
203
  },
81
- async confirmDiscard() {
82
- if (!isDirty)
83
- return true;
84
- if (typeof window === 'undefined')
85
- return true;
86
- return window.confirm(strings.editor.confirmDiscard);
204
+ confirmDiscard() {
205
+ return confirmDiscardRef.current();
87
206
  },
88
207
  focus() {
89
208
  rootElementRef.current?.focus();
90
209
  },
91
- }), [isDirty, strings]);
210
+ }), [isDirty]);
92
211
  const rootElementRef = useRef(null);
93
212
  const handleChange = (state) => {
94
213
  editorStateRef.current = state;
95
214
  const json = JSON.stringify(state.toJSON());
96
215
  setIsDirty(json !== lastSavedJsonRef.current);
97
216
  onChange?.(state.toJSON());
217
+ if (autosave) {
218
+ pendingDraftRef.current = { id: draftId, json };
219
+ if (autosaveTimerRef.current)
220
+ clearTimeout(autosaveTimerRef.current);
221
+ autosaveTimerRef.current = setTimeout(flushDraft, autosaveDelayMs);
222
+ }
223
+ };
224
+ /** A draft was restored into the editor: that content is unsaved work. */
225
+ const handleDraftRestored = (entry) => {
226
+ setIsDirty(true);
227
+ if (documentId && entry.id === documentId)
228
+ onDraftRestored?.({ documentId, savedAt: entry.draft.savedAt });
229
+ // Without a documentId the restored draft becomes this editor's own, so
230
+ // later edits update it instead of piling up copies.
231
+ if (!documentId)
232
+ setAdoptedDraftId(entry.id);
98
233
  };
99
234
  const handleSave = () => {
100
235
  if (!editorStateRef.current)
@@ -104,6 +239,15 @@ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialCo
104
239
  const content = defaultFormatRegistry.serialize(format, json);
105
240
  lastSavedJsonRef.current = JSON.stringify(json);
106
241
  setIsDirty(false);
242
+ // A successful explicit save supersedes any in-progress/queued autosave
243
+ // draft — don't let a stale one linger and falsely prompt to "restore"
244
+ // older content next time this document is opened (§6.2).
245
+ if (autosaveTimerRef.current) {
246
+ clearTimeout(autosaveTimerRef.current);
247
+ autosaveTimerRef.current = null;
248
+ }
249
+ pendingDraftRef.current = null;
250
+ clearDraft(draftId);
107
251
  onSave?.(content, format);
108
252
  };
109
253
  const dir = locale === 'en' ? 'ltr' : 'rtl';
@@ -112,5 +256,24 @@ export const EditorRoot = forwardRef(function EditorRoot({ documentId, initialCo
112
256
  // rather than colliding on a shared one.
113
257
  const portalTargetId = useId();
114
258
  const portalTargetSelector = `#${CSS.escape(portalTargetId)}`;
115
- return (_jsx(EditorThemeProvider, { theme: theme, colorScheme: colorScheme, accentColor: accentColor, scopeElementId: portalTargetId, children: _jsxs("div", { className: "likhari-root", dir: dir, ref: rootElementRef, "data-document-id": documentId, style: { height: typeof height === 'number' ? `${height}px` : height }, children: [_jsx("div", { id: portalTargetId, className: "likhari-portal-target" }), _jsx(PortalTargetContext.Provider, { value: portalTargetSelector, children: _jsx(UiStringsContext.Provider, { value: strings, children: _jsx(ImageOptionsContext.Provider, { value: imageOptions, children: _jsxs(LexicalComposer, { initialConfig: initialConfig, children: [_jsx(Toolbar, { config: config, onSave: handleSave, isDirty: isDirty, showSave: showSave, fontOptions: fontOptions, direction: dir, locale: locale }), _jsx("div", { className: "likhari-canvas", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { className: "likhari-content-editable", dir: dir, "aria-label": strings.editor.contentLabel }), placeholder: _jsx("div", { className: "likhari-placeholder", children: resolvedPlaceholder }), ErrorBoundary: LexicalErrorBoundary }) }), config.history && _jsx(HistoryPlugin, {}), (config.lists.bullet || config.lists.numbered || config.lists.check) && _jsx(ListPlugin, {}), config.lists.check && _jsx(CheckListPlugin, {}), config.links && _jsx(LinkPlugin, {}), config.links && _jsx(LinkPastePlugin, {}), config.blocks.pageBreak && _jsx(PageBreakPlugin, {}), _jsx(OnChangePlugin, { onChange: handleChange })] }) }) }) })] }) }));
259
+ return (_jsx(EditorThemeProvider, { theme: theme, colorScheme: colorScheme, accentColor: accentColor, scopeElementId: portalTargetId, children: _jsxs("div", { className: "likhari-root", dir: dir, ref: rootElementRef, "data-document-id": documentId, style: { height: typeof height === 'number' ? `${height}px` : height }, children: [_jsx("div", { id: portalTargetId, className: "likhari-portal-target" }), _jsx(PortalTargetContext.Provider, { value: portalTargetSelector, children: _jsxs(UiStringsContext.Provider, { value: strings, children: [_jsx(ImageOptionsContext.Provider, { value: imageOptions, children: _jsxs(LexicalComposer, { initialConfig: initialConfig, children: [_jsx(Toolbar, { config: config, onSave: handleSave, isDirty: isDirty, showSave: showSave, fontOptions: fontOptions, direction: dir, locale: locale, drafts: autosave
260
+ ? { currentId: draftId, maxBytes: autosaveMaxBytes, adoptOnRestore: !documentId, onRestored: handleDraftRestored }
261
+ : undefined }), documentId && (_jsx(DraftRestore, { draftId: documentId, mode: restoreDraft, initialJson: initialContentJsonRef.current, locale: locale, onRestored: handleDraftRestored })), _jsx("div", { className: "likhari-canvas", children: _jsx(RichTextPlugin, { contentEditable: _jsx(ContentEditable, { className: "likhari-content-editable", dir: dir, "aria-label": strings.editor.contentLabel }), placeholder: _jsx("div", { className: "likhari-placeholder", children: resolvedPlaceholder }), ErrorBoundary: LexicalErrorBoundary }) }), config.history && _jsx(HistoryPlugin, {}), (config.lists.bullet || config.lists.numbered || config.lists.check) && _jsx(ListPlugin, {}), config.lists.check && _jsx(CheckListPlugin, {}), config.links && _jsx(LinkPlugin, {}), config.links && _jsx(LinkPastePlugin, {}), _jsx(OnChangePlugin, { onChange: handleChange })] }) }), _jsx(LeaveDialog, { opened: leavePrompt !== null, canSave: Boolean(onSave), canSaveDraft: autosave || navigationGuard === 'save-draft', draftFailed: leavePrompt?.draftFailed ?? false, onSave: () => {
262
+ handleSave();
263
+ settleLeave(true);
264
+ }, onSaveDraft: () => {
265
+ if (saveDraftNow())
266
+ settleLeave(true);
267
+ else
268
+ setLeavePrompt({ draftFailed: true });
269
+ }, onDiscard: () => {
270
+ // The user chose to drop this work, so no draft of it should linger either.
271
+ if (autosaveTimerRef.current) {
272
+ clearTimeout(autosaveTimerRef.current);
273
+ autosaveTimerRef.current = null;
274
+ }
275
+ pendingDraftRef.current = null;
276
+ clearDraft(draftId);
277
+ settleLeave(true);
278
+ }, onStay: () => settleLeave(false) })] }) })] }) }));
116
279
  });
@@ -0,0 +1,24 @@
1
+ import { type DraftEntry } from '../persistence/draftStorage.js';
2
+ import type { Locale } from '../i18n/index.js';
3
+ /** What to do on mount with a stored draft of this document that is newer than the initial content. */
4
+ export type DraftRestoreMode = 'prompt' | 'auto' | 'off';
5
+ export interface DraftRestoreProps {
6
+ /** The host's `documentId` — the key the draft is stored under. */
7
+ draftId: string;
8
+ mode: DraftRestoreMode;
9
+ /** The Lexical JSON the editor started with; a draft identical to it isn't offered. */
10
+ initialJson?: string;
11
+ locale: Locale;
12
+ onRestored: (entry: DraftEntry) => void;
13
+ }
14
+ /**
15
+ * On mount, handles a draft of this document newer than the initial content
16
+ * (lexical-editor-spec.md §6.2), per `mode`:
17
+ * - `prompt` (default): a banner — Restore, Ignore (kept in the drafts list,
18
+ * since new edits would otherwise overwrite it) or Remove. Never picks silently.
19
+ * - `auto`: loads the draft straight away, no banner.
20
+ * - `off`: leaves the editor on its initial content, but moves the draft aside
21
+ * as an earlier version so new edits can't overwrite it unseen.
22
+ */
23
+ export declare function DraftRestore({ draftId, mode, initialJson, locale, onRestored }: DraftRestoreProps): import("react").JSX.Element | null;
24
+ //# sourceMappingURL=DraftRestore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DraftRestore.d.ts","sourceRoot":"","sources":["../../src/components/DraftRestore.tsx"],"names":[],"mappings":"AAGA,OAAO,EAAwD,KAAK,UAAU,EAAoB,MAAM,6BAA6B,CAAC;AAGtI,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAEtC,uGAAuG;AACvG,MAAM,MAAM,gBAAgB,GAAG,QAAQ,GAAG,MAAM,GAAG,KAAK,CAAC;AAEzD,MAAM,WAAW,iBAAiB;IAChC,mEAAmE;IACnE,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,gBAAgB,CAAC;IACvB,uFAAuF;IACvF,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;CACzC;AAED;;;;;;;;GAQG;AACH,wBAAgB,YAAY,CAAC,EAAE,OAAO,EAAE,IAAI,EAAE,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,iBAAiB,sCA8DjG"}
@@ -0,0 +1,61 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from 'react';
3
+ import { IconHistory } from '@tabler/icons-react';
4
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
5
+ import { archiveDraft, clearDraft, hasDraftContent, readDraft } from '../persistence/draftStorage.js';
6
+ import { applyDraftToEditor, formatDraftTime } from '../persistence/draftUi.js';
7
+ import { useUiStrings } from '../i18n/useStrings.js';
8
+ /**
9
+ * On mount, handles a draft of this document newer than the initial content
10
+ * (lexical-editor-spec.md §6.2), per `mode`:
11
+ * - `prompt` (default): a banner — Restore, Ignore (kept in the drafts list,
12
+ * since new edits would otherwise overwrite it) or Remove. Never picks silently.
13
+ * - `auto`: loads the draft straight away, no banner.
14
+ * - `off`: leaves the editor on its initial content, but moves the draft aside
15
+ * as an earlier version so new edits can't overwrite it unseen.
16
+ */
17
+ export function DraftRestore({ draftId, mode, initialJson, locale, onRestored }) {
18
+ const [editor] = useLexicalComposerContext();
19
+ const strings = useUiStrings();
20
+ const [draft, setDraft] = useState(() => {
21
+ const stored = readDraft(draftId);
22
+ return stored && hasDraftContent(stored.json) && stored.json !== initialJson ? stored : null;
23
+ });
24
+ const handled = useRef(false);
25
+ const restore = () => {
26
+ if (!draft)
27
+ return;
28
+ if (applyDraftToEditor(editor, draft.json))
29
+ onRestored({ id: draftId, draft });
30
+ // A corrupt or incompatible draft can't be restored: drop the banner and keep the initial content.
31
+ setDraft(null);
32
+ };
33
+ const ignore = () => {
34
+ // Move it aside, so edits from here on can't overwrite it unseen.
35
+ if (archiveDraft(draftId))
36
+ clearDraft(draftId);
37
+ setDraft(null);
38
+ };
39
+ // Non-interactive modes act once, on mount.
40
+ useEffect(() => {
41
+ if (handled.current || !draft)
42
+ return;
43
+ handled.current = true;
44
+ if (mode === 'auto')
45
+ restore();
46
+ else if (mode === 'off')
47
+ ignore();
48
+ // Deliberately runs once on mount only.
49
+ // eslint-disable-next-line react-hooks/exhaustive-deps
50
+ }, []);
51
+ const remove = () => {
52
+ clearDraft(draftId);
53
+ setDraft(null);
54
+ };
55
+ if (!draft || mode !== 'prompt')
56
+ return null;
57
+ // Plain elements styled with the editor's own tokens (editor.css): Mantine's
58
+ // theme variables are scoped to the portal target, so an in-flow Mantine
59
+ // button here would fall back to Mantine's default blue.
60
+ return (_jsxs("div", { className: "likhari-draft-banner", role: "status", "aria-live": "polite", children: [_jsxs("div", { className: "likhari-draft-banner-message", children: [_jsx(IconHistory, { size: 18, stroke: 1.75, "aria-hidden": "true" }), _jsx("span", { children: strings.drafts.bannerMessage(formatDraftTime(draft.savedAt, locale)) })] }), _jsxs("div", { className: "likhari-draft-banner-actions", children: [_jsx("button", { type: "button", className: "likhari-draft-banner-button", "data-variant": "primary", onClick: restore, children: strings.drafts.restore }), _jsx("button", { type: "button", className: "likhari-draft-banner-button", onClick: ignore, children: strings.drafts.ignore }), _jsx("button", { type: "button", className: "likhari-draft-banner-button", "data-variant": "danger", onClick: remove, children: strings.drafts.remove })] })] }));
61
+ }
@@ -0,0 +1,25 @@
1
+ import { type DraftEntry } from '../persistence/draftStorage.js';
2
+ import type { Locale } from '../i18n/index.js';
3
+ /** What the toolbar needs to offer the drafts list. */
4
+ export interface DraftsToolbarOptions {
5
+ /** The key this editor's own autosave writes to. */
6
+ currentId: string;
7
+ /** Size cap for archiving the content a restore replaces. */
8
+ maxBytes: number;
9
+ /** Without a `documentId`, a restored draft becomes this editor's draft, so later edits update it instead of duplicating it. */
10
+ adoptOnRestore: boolean;
11
+ onRestored: (entry: DraftEntry) => void;
12
+ }
13
+ export interface DraftsDialogProps extends DraftsToolbarOptions {
14
+ opened: boolean;
15
+ locale: Locale;
16
+ onClose: () => void;
17
+ }
18
+ /**
19
+ * Every autosaved draft in this browser (all documents, and the earlier
20
+ * versions kept when a restore prompt was ignored), newest first, each with
21
+ * Restore and Delete. Restoring over content that differs asks first, and keeps
22
+ * the replaced content as a draft of its own, so nothing is lost either way.
23
+ */
24
+ export declare function DraftsDialog({ opened, locale, currentId, maxBytes, adoptOnRestore, onRestored, onClose }: DraftsDialogProps): import("react").JSX.Element;
25
+ //# sourceMappingURL=DraftsDialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"DraftsDialog.d.ts","sourceRoot":"","sources":["../../src/components/DraftsDialog.tsx"],"names":[],"mappings":"AAGA,OAAO,EAOL,KAAK,UAAU,EAChB,MAAM,6BAA6B,CAAC;AAIrC,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAEtC,uDAAuD;AACvD,MAAM,WAAW,oBAAoB;IACnC,oDAAoD;IACpD,SAAS,EAAE,MAAM,CAAC;IAClB,6DAA6D;IAC7D,QAAQ,EAAE,MAAM,CAAC;IACjB,gIAAgI;IAChI,cAAc,EAAE,OAAO,CAAC;IACxB,UAAU,EAAE,CAAC,KAAK,EAAE,UAAU,KAAK,IAAI,CAAC;CACzC;AAED,MAAM,WAAW,iBAAkB,SAAQ,oBAAoB;IAC7D,MAAM,EAAE,OAAO,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AAID;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,QAAQ,EAAE,cAAc,EAAE,UAAU,EAAE,OAAO,EAAE,EAAE,iBAAiB,+BAmH3H"}
@@ -0,0 +1,66 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { Badge, Button, Group, Modal, Paper, Stack, Text } from '@mantine/core';
4
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
5
+ import { clearDraft, createDraftId, hasDraftContent, isGeneratedDraftId, listDrafts, writeDraft, } from '../persistence/draftStorage.js';
6
+ import { applyDraftToEditor, formatDraftTime } from '../persistence/draftUi.js';
7
+ import { useUiStrings } from '../i18n/useStrings.js';
8
+ import { usePortalTarget } from '../PortalTargetContext.js';
9
+ /**
10
+ * Every autosaved draft in this browser (all documents, and the earlier
11
+ * versions kept when a restore prompt was ignored), newest first, each with
12
+ * Restore and Delete. Restoring over content that differs asks first, and keeps
13
+ * the replaced content as a draft of its own, so nothing is lost either way.
14
+ */
15
+ export function DraftsDialog({ opened, locale, currentId, maxBytes, adoptOnRestore, onRestored, onClose }) {
16
+ const [editor] = useLexicalComposerContext();
17
+ const strings = useUiStrings();
18
+ const portalTarget = usePortalTarget();
19
+ const [entries, setEntries] = useState([]);
20
+ const [pending, setPending] = useState(null);
21
+ useEffect(() => {
22
+ if (opened) {
23
+ setEntries(listDrafts());
24
+ setPending(null);
25
+ }
26
+ }, [opened]);
27
+ const currentJson = () => JSON.stringify(editor.getEditorState().toJSON());
28
+ const restore = (entry) => {
29
+ const before = currentJson();
30
+ // Keep what is being replaced, as a draft of its own.
31
+ if (before !== entry.draft.json && hasDraftContent(before)) {
32
+ writeDraft(createDraftId(), before, maxBytes, currentId);
33
+ if (adoptOnRestore)
34
+ clearDraft(currentId);
35
+ }
36
+ if (applyDraftToEditor(editor, entry.draft.json)) {
37
+ onRestored(entry);
38
+ onClose();
39
+ }
40
+ };
41
+ const requestRestore = (entry) => {
42
+ const before = currentJson();
43
+ // Replacing real, different content is confirmed first (inline, not a window.confirm).
44
+ if (before !== entry.draft.json && hasDraftContent(before))
45
+ setPending({ id: entry.id, action: 'restore' });
46
+ else
47
+ restore(entry);
48
+ };
49
+ const remove = (entry) => {
50
+ clearDraft(entry.id);
51
+ setEntries(listDrafts());
52
+ setPending(null);
53
+ };
54
+ const label = (entry) => {
55
+ const source = entry.draft.sourceId;
56
+ if (source)
57
+ return isGeneratedDraftId(source) ? strings.drafts.earlierUntitled : strings.drafts.earlierVersionOf(source);
58
+ return isGeneratedDraftId(entry.id) ? strings.drafts.untitled : strings.drafts.documentLabel(entry.id);
59
+ };
60
+ return (_jsx(Modal, { opened: opened, onClose: onClose, title: strings.drafts.dialogTitle, centered: true, size: "lg", portalProps: portalTarget ? { target: portalTarget } : undefined, children: _jsxs(Stack, { gap: "sm", children: [_jsx(Text, { size: "sm", c: "dimmed", children: strings.drafts.intro }), entries.length === 0 && _jsx(Text, { size: "sm", children: strings.drafts.empty }), entries.map((entry) => {
61
+ const isCurrent = entry.id === currentId;
62
+ const isPending = pending?.id === entry.id;
63
+ return (_jsx(Paper, { withBorder: true, p: "sm", radius: "sm", "data-draft-id": entry.id, children: _jsxs(Stack, { gap: 6, children: [_jsxs(Group, { justify: "space-between", wrap: "nowrap", align: "flex-start", children: [_jsx(Text, { size: "sm", lineClamp: 2, style: { flex: 1, wordBreak: 'break-word' }, children: entry.draft.preview || strings.drafts.noText }), isCurrent && _jsx(Badge, { variant: "light", children: strings.drafts.thisDocument })] }), _jsxs(Text, { size: "xs", c: "dimmed", children: [label(entry), " \u00B7 ", strings.drafts.savedAt(formatDraftTime(entry.draft.savedAt, locale)), " \u00B7", ' ', strings.drafts.size(Math.max(1, Math.round(entry.draft.json.length / 1024)))] }), !isCurrent &&
64
+ (isPending ? (_jsxs(Group, { gap: "xs", align: "center", children: [_jsx(Text, { size: "sm", children: pending.action === 'restore' ? strings.drafts.replacePrompt : strings.drafts.deletePrompt }), _jsx(Button, { size: "compact-sm", color: pending.action === 'delete' ? 'red' : undefined, onClick: () => (pending.action === 'restore' ? restore(entry) : remove(entry)), children: pending.action === 'restore' ? strings.drafts.replace : strings.drafts.delete }), _jsx(Button, { size: "compact-sm", variant: "default", onClick: () => setPending(null), children: strings.common.cancel })] })) : (_jsxs(Group, { gap: "xs", children: [_jsx(Button, { size: "compact-sm", onClick: () => requestRestore(entry), children: strings.drafts.restore }), _jsx(Button, { size: "compact-sm", variant: "subtle", color: "red", onClick: () => setPending({ id: entry.id, action: 'delete' }), children: strings.drafts.delete })] })))] }) }, entry.id));
65
+ }), _jsx(Group, { justify: "flex-end", children: _jsx(Button, { variant: "default", onClick: onClose, children: strings.drafts.close }) })] }) }));
66
+ }
@@ -0,0 +1,22 @@
1
+ export interface LeaveDialogProps {
2
+ opened: boolean;
3
+ /** Offer "Save" (calls the host's `onSave`, then lets the user leave). */
4
+ canSave: boolean;
5
+ /** Offer "Save draft" (keeps the work in this browser, then lets the user leave). */
6
+ canSaveDraft: boolean;
7
+ /** A "Save draft" attempt failed; explains why the dialog is still open. */
8
+ draftFailed: boolean;
9
+ onSave: () => void;
10
+ onSaveDraft: () => void;
11
+ onDiscard: () => void;
12
+ /** Stay on the page (also Escape, the close button, and clicking outside). */
13
+ onStay: () => void;
14
+ }
15
+ /**
16
+ * The in-editor popup behind `EditorRef.confirmDiscard()`: what to do with
17
+ * unsaved changes before leaving. It replaces `window.confirm()`, which can't
18
+ * be styled, translated per editor, or offer more than OK/Cancel. (The browser's
19
+ * own tab-close prompt can't be replaced — see `navigationGuard`.)
20
+ */
21
+ export declare function LeaveDialog({ opened, canSave, canSaveDraft, draftFailed, onSave, onSaveDraft, onDiscard, onStay }: LeaveDialogProps): import("react").JSX.Element;
22
+ //# sourceMappingURL=LeaveDialog.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"LeaveDialog.d.ts","sourceRoot":"","sources":["../../src/components/LeaveDialog.tsx"],"names":[],"mappings":"AAIA,MAAM,WAAW,gBAAgB;IAC/B,MAAM,EAAE,OAAO,CAAC;IAChB,0EAA0E;IAC1E,OAAO,EAAE,OAAO,CAAC;IACjB,qFAAqF;IACrF,YAAY,EAAE,OAAO,CAAC;IACtB,4EAA4E;IAC5E,WAAW,EAAE,OAAO,CAAC;IACrB,MAAM,EAAE,MAAM,IAAI,CAAC;IACnB,WAAW,EAAE,MAAM,IAAI,CAAC;IACxB,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB,8EAA8E;IAC9E,MAAM,EAAE,MAAM,IAAI,CAAC;CACpB;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,EAAE,MAAM,EAAE,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,EAAE,EAAE,gBAAgB,+BAoCnI"}
@@ -0,0 +1,15 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Alert, Button, Group, Modal, Stack, Text } from '@mantine/core';
3
+ import { useUiStrings } from '../i18n/useStrings.js';
4
+ import { usePortalTarget } from '../PortalTargetContext.js';
5
+ /**
6
+ * The in-editor popup behind `EditorRef.confirmDiscard()`: what to do with
7
+ * unsaved changes before leaving. It replaces `window.confirm()`, which can't
8
+ * be styled, translated per editor, or offer more than OK/Cancel. (The browser's
9
+ * own tab-close prompt can't be replaced — see `navigationGuard`.)
10
+ */
11
+ export function LeaveDialog({ opened, canSave, canSaveDraft, draftFailed, onSave, onSaveDraft, onDiscard, onStay }) {
12
+ const strings = useUiStrings();
13
+ const portalTarget = usePortalTarget();
14
+ return (_jsx(Modal, { opened: opened, onClose: onStay, title: strings.leaveDialog.title, centered: true, size: "md", portalProps: portalTarget ? { target: portalTarget } : undefined, children: _jsxs(Stack, { gap: "md", children: [_jsx(Text, { size: "sm", children: strings.leaveDialog.message }), draftFailed && (_jsx(Alert, { color: "red", variant: "light", p: "xs", children: strings.leaveDialog.saveDraftFailed })), _jsxs(Group, { justify: "flex-end", gap: "xs", wrap: "wrap", children: [_jsx(Button, { variant: "default", onClick: onStay, "data-autofocus": true, children: strings.leaveDialog.stay }), _jsx(Button, { variant: "subtle", color: "red", onClick: onDiscard, children: strings.leaveDialog.discard }), canSaveDraft && (_jsx(Button, { variant: "default", onClick: onSaveDraft, children: strings.leaveDialog.saveDraft })), canSave && _jsx(Button, { onClick: onSave, children: strings.leaveDialog.save })] })] }) }));
15
+ }
@@ -1,3 +1,4 @@
1
+ import { type DraftsToolbarOptions } from './DraftsDialog.js';
1
2
  import type { ResolvedEditorFeatureConfig } from '@inshapardaz/likhari-core';
2
3
  import { type FontOption } from '../fonts/index.js';
3
4
  import type { Locale } from '../i18n/strings.js';
@@ -12,6 +13,8 @@ export interface ToolbarProps {
12
13
  direction?: 'ltr' | 'rtl';
13
14
  /** UI locale — picks the translated strings for labels, tooltips and menu items. */
14
15
  locale?: Locale;
16
+ /** Autosave drafts: when set, the toolbar offers a Drafts button that lists and restores them. */
17
+ drafts?: DraftsToolbarOptions;
15
18
  }
16
- export declare function Toolbar({ config, onSave, isDirty, showSave, fontOptions, direction, locale }: ToolbarProps): import("react").JSX.Element;
19
+ export declare function Toolbar({ config, onSave, isDirty, showSave, fontOptions, direction, locale, drafts }: ToolbarProps): import("react").JSX.Element;
17
20
  //# sourceMappingURL=Toolbar.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"AA6CA,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,2BAA2B,CAAC;AAC7E,OAAO,EAA6D,KAAK,UAAU,EAAE,MAAM,UAAU,CAAC;AAItG,OAAO,KAAK,EAAE,MAAM,EAAW,MAAM,iBAAiB,CAAC;AAsUvD,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,2BAA2B,CAAC;IACpC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,WAAW,CAAC,EAAE,UAAU,EAAE,CAAC;IAC3B,wFAAwF;IACxF,SAAS,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC;IAC1B,oFAAoF;IACpF,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,wBAAgB,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAkC,EAAE,SAAiB,EAAE,MAAa,EAAE,EAAE,YAAY,+BAqxBhJ"}
1
+ {"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"AA2CA,OAAO,EAAgB,KAAK,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAEzE,OAAO,KAAK,EAAE,2BAA2B,EAAE,MAAM,2BAA2B,CAAC;AAC7E,OAAO,EAA6D,KAAK,UAAU,EAAE,MAAM,UAAU,CAAC;AAItG,OAAO,KAAK,EAAE,MAAM,EAAW,MAAM,iBAAiB,CAAC;AAsUvD,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,2BAA2B,CAAC;IACpC,MAAM,CAAC,EAAE,MAAM,IAAI,CAAC;IACpB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,sEAAsE;IACtE,WAAW,CAAC,EAAE,UAAU,EAAE,CAAC;IAC3B,wFAAwF;IACxF,SAAS,CAAC,EAAE,KAAK,GAAG,KAAK,CAAC;IAC1B,oFAAoF;IACpF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kGAAkG;IAClG,MAAM,CAAC,EAAE,oBAAoB,CAAC;CAC/B;AAED,wBAAgB,OAAO,CAAC,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,EAAE,QAAQ,EAAE,WAAkC,EAAE,SAAiB,EAAE,MAAa,EAAE,MAAM,EAAE,EAAE,YAAY,+BAqxBxJ"}