@inshapardaz/likhari-react 0.1.8 → 0.1.9-dev.94

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 (35) hide show
  1. package/dist/EditorRoot.d.ts +60 -0
  2. package/dist/EditorRoot.d.ts.map +1 -1
  3. package/dist/EditorRoot.js +183 -10
  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/TableDialog.d.ts +17 -0
  14. package/dist/components/TableDialog.d.ts.map +1 -0
  15. package/dist/components/TableDialog.js +33 -0
  16. package/dist/components/Toolbar.d.ts +4 -1
  17. package/dist/components/Toolbar.d.ts.map +1 -1
  18. package/dist/components/Toolbar.js +31 -5
  19. package/dist/i18n/strings.d.ts +45 -2
  20. package/dist/i18n/strings.d.ts.map +1 -1
  21. package/dist/i18n/strings.js +117 -3
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/nodes.d.ts.map +1 -1
  25. package/dist/nodes.js +4 -0
  26. package/dist/persistence/draftStorage.d.ts +55 -0
  27. package/dist/persistence/draftStorage.d.ts.map +1 -0
  28. package/dist/persistence/draftStorage.js +198 -0
  29. package/dist/persistence/draftUi.d.ts +9 -0
  30. package/dist/persistence/draftUi.d.ts.map +1 -0
  31. package/dist/persistence/draftUi.js +23 -0
  32. package/dist/theme/editor.css +105 -0
  33. package/dist/theme/editorTheme.d.ts.map +1 -1
  34. package/dist/theme/editorTheme.js +5 -0
  35. package/package.json +2 -1
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Autosave-to-localStorage drafts (lexical-editor-spec.md §6.2). Each draft is
3
+ * keyed by an id: the host's `documentId` when it gives one (§6.1), otherwise
4
+ * a generated unique id, so editors without a `documentId` never overwrite each
5
+ * other's drafts. Every function is a no-op (never throws) when `localStorage`
6
+ * is unavailable (SSR, privacy mode) or a stored value is corrupt — autosave is
7
+ * best-effort, never a hard dependency.
8
+ */
9
+ const KEY_PREFIX = 'likhari:draft:';
10
+ const PREVIEW_LENGTH = 140;
11
+ export function draftKey(id) {
12
+ return `${KEY_PREFIX}${id}`;
13
+ }
14
+ /** A new unique id for a draft whose editor has no `documentId`. */
15
+ export function createDraftId() {
16
+ try {
17
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
18
+ return crypto.randomUUID();
19
+ }
20
+ catch {
21
+ // fall through to the non-crypto id
22
+ }
23
+ return `draft-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
24
+ }
25
+ /** Whether an id came from createDraftId() rather than a host's `documentId`. */
26
+ export function isGeneratedDraftId(id) {
27
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id) || /^draft-[0-9a-z]+-[0-9a-z]+$/.test(id);
28
+ }
29
+ function getLocalStorage() {
30
+ try {
31
+ return typeof localStorage === 'undefined' ? null : localStorage;
32
+ }
33
+ catch {
34
+ // Some browsers throw on accessing localStorage in certain privacy modes.
35
+ return null;
36
+ }
37
+ }
38
+ function collectText(node, parts, state) {
39
+ if (node.type === 'text' && typeof node.text === 'string') {
40
+ if (node.text.trim())
41
+ state.hasContent = true;
42
+ parts.push(node.text);
43
+ }
44
+ else if (node.type === 'linebreak') {
45
+ parts.push(' ');
46
+ }
47
+ else if (node.type === 'image') {
48
+ state.hasContent = true;
49
+ if (node.altText)
50
+ parts.push(`[${node.altText}]`);
51
+ }
52
+ for (const child of node.children ?? [])
53
+ collectText(child, parts, state);
54
+ // Block boundaries read as a space in a one-line preview.
55
+ if (node.children && node.type !== 'root')
56
+ parts.push(' ');
57
+ }
58
+ function summarize(json) {
59
+ try {
60
+ const root = JSON.parse(json).root;
61
+ if (!root)
62
+ return { preview: '', hasContent: false };
63
+ const parts = [];
64
+ const state = { hasContent: false };
65
+ collectText(root, parts, state);
66
+ const text = parts.join('').replace(/\s+/g, ' ').trim();
67
+ const preview = text.length > PREVIEW_LENGTH ? `${text.slice(0, PREVIEW_LENGTH).trimEnd()}…` : text;
68
+ return { preview, hasContent: state.hasContent };
69
+ }
70
+ catch {
71
+ return { preview: '', hasContent: false };
72
+ }
73
+ }
74
+ /** One-line text preview of a Lexical JSON document ('' when it has no text). */
75
+ export function extractPreview(json) {
76
+ return summarize(json).preview;
77
+ }
78
+ /** Whether a Lexical JSON document has any text or image (a blank one isn't worth a draft). */
79
+ export function hasDraftContent(json) {
80
+ return summarize(json).hasContent;
81
+ }
82
+ function parseDraft(raw) {
83
+ if (!raw)
84
+ return null;
85
+ try {
86
+ const parsed = JSON.parse(raw);
87
+ if (typeof parsed.json !== 'string' || typeof parsed.savedAt !== 'number' || parsed.version !== 1)
88
+ return null;
89
+ return parsed;
90
+ }
91
+ catch {
92
+ return null;
93
+ }
94
+ }
95
+ export function readDraft(id) {
96
+ const storage = getLocalStorage();
97
+ if (!storage)
98
+ return null;
99
+ try {
100
+ return parseDraft(storage.getItem(draftKey(id)));
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ /**
107
+ * Best-effort write; returns whether the draft was actually stored (callers that
108
+ * promise the user a saved draft must check). Skips (rather than throwing) when `json` exceeds
109
+ * `maxBytes` — localStorage has a shared ~5-10MB per-origin ceiling (§6.2), so a
110
+ * single large document (e.g. embedded base64 images) must not be allowed to
111
+ * autosave past a caller-configured limit — or when the write itself fails
112
+ * (quota exceeded, privacy mode).
113
+ */
114
+ export function writeDraft(id, json, maxBytes, sourceId) {
115
+ const storage = getLocalStorage();
116
+ if (!storage)
117
+ return false;
118
+ // .length (UTF-16 code units) over-estimates UTF-8 byte size for any
119
+ // non-Latin text, which only makes this check more conservative — fine
120
+ // for a soft cap that doesn't need to be exact.
121
+ if (json.length > maxBytes)
122
+ return false;
123
+ const draft = { json, savedAt: Date.now(), version: 1, preview: extractPreview(json) };
124
+ if (sourceId)
125
+ draft.sourceId = sourceId;
126
+ try {
127
+ storage.setItem(draftKey(id), JSON.stringify(draft));
128
+ return true;
129
+ }
130
+ catch {
131
+ // Quota exceeded or similar — autosave is best-effort.
132
+ return false;
133
+ }
134
+ }
135
+ export function clearDraft(id) {
136
+ const storage = getLocalStorage();
137
+ if (!storage)
138
+ return;
139
+ try {
140
+ storage.removeItem(draftKey(id));
141
+ }
142
+ catch {
143
+ // ignore
144
+ }
145
+ }
146
+ /** Every stored draft, newest first. */
147
+ export function listDrafts() {
148
+ const storage = getLocalStorage();
149
+ if (!storage)
150
+ return [];
151
+ const entries = [];
152
+ try {
153
+ for (let i = 0; i < storage.length; i++) {
154
+ const key = storage.key(i);
155
+ if (!key || !key.startsWith(KEY_PREFIX))
156
+ continue;
157
+ const draft = parseDraft(storage.getItem(key));
158
+ if (draft)
159
+ entries.push({ id: key.slice(KEY_PREFIX.length), draft });
160
+ }
161
+ }
162
+ catch {
163
+ return entries;
164
+ }
165
+ return entries.sort((a, b) => b.draft.savedAt - a.draft.savedAt);
166
+ }
167
+ /**
168
+ * Keeps a draft that is about to be overwritten or ignored: copies it to a new
169
+ * generated id (remembering `sourceId`) so it stays in the drafts list.
170
+ * Returns the new id, or null if nothing could be copied.
171
+ */
172
+ export function archiveDraft(id) {
173
+ const storage = getLocalStorage();
174
+ const draft = readDraft(id);
175
+ if (!storage || !draft)
176
+ return null;
177
+ const archivedId = createDraftId();
178
+ try {
179
+ storage.setItem(draftKey(archivedId), JSON.stringify({ ...draft, sourceId: draft.sourceId ?? id }));
180
+ return archivedId;
181
+ }
182
+ catch {
183
+ return null;
184
+ }
185
+ }
186
+ /** Deletes the oldest drafts beyond `max`, never touching `keepIds`. */
187
+ export function pruneDrafts(max, keepIds = []) {
188
+ if (max < 1)
189
+ return;
190
+ const removable = listDrafts().filter((entry) => !keepIds.includes(entry.id));
191
+ const kept = listDrafts().length - removable.length;
192
+ const excess = removable.length + kept - max;
193
+ if (excess <= 0)
194
+ return;
195
+ // removable is newest first, so the oldest are at the end.
196
+ for (const entry of removable.slice(-excess))
197
+ clearDraft(entry.id);
198
+ }
@@ -0,0 +1,9 @@
1
+ import type { LexicalEditor } from 'lexical';
2
+ import type { Locale } from '../i18n/index.js';
3
+ /** Replaces the editor's content with a stored draft's Lexical JSON. Returns
4
+ * false (leaving the editor untouched) if the draft is corrupt or from an
5
+ * incompatible node schema, rather than throwing. */
6
+ export declare function applyDraftToEditor(editor: LexicalEditor, json: string): boolean;
7
+ /** A draft's save time as a short localized date and time. */
8
+ export declare function formatDraftTime(savedAt: number, locale: Locale): string;
9
+ //# sourceMappingURL=draftUi.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"draftUi.d.ts","sourceRoot":"","sources":["../../src/persistence/draftUi.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,SAAS,CAAC;AAC7C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AAEtC;;qDAEqD;AACrD,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,aAAa,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAO/E;AAID,8DAA8D;AAC9D,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,MAAM,CAOvE"}
@@ -0,0 +1,23 @@
1
+ /** Replaces the editor's content with a stored draft's Lexical JSON. Returns
2
+ * false (leaving the editor untouched) if the draft is corrupt or from an
3
+ * incompatible node schema, rather than throwing. */
4
+ export function applyDraftToEditor(editor, json) {
5
+ try {
6
+ editor.setEditorState(editor.parseEditorState(json));
7
+ return true;
8
+ }
9
+ catch {
10
+ return false;
11
+ }
12
+ }
13
+ const INTL_LOCALES = { en: 'en', ur: 'ur', 'pa-shahmukhi': 'pa-Arab' };
14
+ /** A draft's save time as a short localized date and time. */
15
+ export function formatDraftTime(savedAt, locale) {
16
+ const date = new Date(savedAt);
17
+ try {
18
+ return new Intl.DateTimeFormat(INTL_LOCALES[locale], { dateStyle: 'medium', timeStyle: 'short' }).format(date);
19
+ }
20
+ catch {
21
+ return date.toLocaleString();
22
+ }
23
+ }
@@ -253,6 +253,37 @@
253
253
  margin: 1.5em 0;
254
254
  }
255
255
 
256
+ /* Tables — cell merge/split is deliberately out of scope for now (a flagged
257
+ open risk, lexical-editor-spec.md §12 item 3, needing its own scope
258
+ decision); TablePlugin is configured with hasCellMerge={false} to keep
259
+ every table a plain grid until that's resolved. */
260
+ .likhari-table {
261
+ border-collapse: collapse;
262
+ margin: 1em 0;
263
+ width: 100%;
264
+ }
265
+
266
+ .likhari-table-row {
267
+ border-bottom: 1px solid var(--editor-line);
268
+ }
269
+
270
+ .likhari-table-cell {
271
+ border: 1px solid var(--editor-line);
272
+ padding: 6px 10px;
273
+ min-width: 72px;
274
+ vertical-align: top;
275
+ }
276
+
277
+ .likhari-table-cell-header {
278
+ background: var(--editor-paper-dim);
279
+ font-weight: 600;
280
+ text-align: start;
281
+ }
282
+
283
+ .likhari-table-cell-selected {
284
+ background: var(--editor-accent-soft);
285
+ }
286
+
256
287
  /* Images — block-level figure; the caption is static text (edited in the
257
288
  image's dialog), and a selected image gets a corner resize handle. */
258
289
  .likhari-image-block {
@@ -370,3 +401,77 @@
370
401
  text-overflow: ellipsis;
371
402
  white-space: nowrap;
372
403
  }
404
+
405
+ /* Restore-draft banner: a strip between the toolbar and the canvas. Uses the
406
+ editor's own tokens, so it follows the accent color and dark mode. */
407
+ .likhari-draft-banner {
408
+ display: flex;
409
+ flex-shrink: 0;
410
+ flex-wrap: wrap;
411
+ align-items: center;
412
+ gap: 8px 12px;
413
+ padding: 8px 12px;
414
+ background: var(--editor-accent-soft);
415
+ border-bottom: 1px solid var(--editor-line);
416
+ color: var(--editor-ink);
417
+ font-size: 13px;
418
+ }
419
+
420
+ .likhari-draft-banner-message {
421
+ display: flex;
422
+ flex: 1 1 240px;
423
+ align-items: center;
424
+ gap: 8px;
425
+ }
426
+
427
+ .likhari-draft-banner-message svg {
428
+ flex-shrink: 0;
429
+ color: var(--editor-accent);
430
+ }
431
+
432
+ .likhari-draft-banner-actions {
433
+ display: flex;
434
+ flex-wrap: wrap;
435
+ gap: 6px;
436
+ }
437
+
438
+ .likhari-draft-banner-button {
439
+ height: 28px;
440
+ padding: 0 12px;
441
+ border: 1px solid var(--editor-line-strong);
442
+ border-radius: 6px;
443
+ background: var(--editor-paper);
444
+ color: var(--editor-ink);
445
+ font: inherit;
446
+ font-weight: 500;
447
+ cursor: pointer;
448
+ }
449
+
450
+ .likhari-draft-banner-button:hover {
451
+ background: var(--editor-paper-dim);
452
+ }
453
+
454
+ .likhari-draft-banner-button[data-variant='primary'] {
455
+ border-color: var(--editor-accent);
456
+ background: var(--editor-accent);
457
+ color: var(--editor-paper);
458
+ }
459
+
460
+ .likhari-draft-banner-button[data-variant='primary']:hover {
461
+ filter: brightness(1.1);
462
+ }
463
+
464
+ .likhari-draft-banner-button[data-variant='danger'] {
465
+ border-color: transparent;
466
+ background: transparent;
467
+ color: var(--editor-danger);
468
+ }
469
+
470
+ .likhari-draft-banner-button[data-variant='danger']:hover {
471
+ background: var(--editor-paper-dim);
472
+ }
473
+
474
+ .likhari-draft-banner-button:focus-visible {
475
+ outline: 2px solid var(--editor-accent);
476
+ outline-offset: 1px;
477
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"editorTheme.d.ts","sourceRoot":"","sources":["../../src/theme/editorTheme.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAElD;;;;GAIG;AACH,eAAO,MAAM,WAAW,EAAE,kBA8BzB,CAAC"}
1
+ {"version":3,"file":"editorTheme.d.ts","sourceRoot":"","sources":["../../src/theme/editorTheme.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,SAAS,CAAC;AAElD;;;;GAIG;AACH,eAAO,MAAM,WAAW,EAAE,kBAmCzB,CAAC"}
@@ -33,4 +33,9 @@ export const editorTheme = {
33
33
  },
34
34
  hr: 'likhari-hr',
35
35
  image: 'likhari-image-block',
36
+ table: 'likhari-table',
37
+ tableRow: 'likhari-table-row',
38
+ tableCell: 'likhari-table-cell',
39
+ tableCellHeader: 'likhari-table-cell-header',
40
+ tableCellSelected: 'likhari-table-cell-selected',
36
41
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inshapardaz/likhari-react",
3
- "version": "0.1.8",
3
+ "version": "0.1.9-dev.94",
4
4
  "description": "React implementation of the Likhari editor, built on @lexical/react.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,6 +38,7 @@
38
38
  "@lexical/react": "^0.20.0",
39
39
  "@lexical/rich-text": "^0.20.0",
40
40
  "@lexical/selection": "^0.20.0",
41
+ "@lexical/table": "^0.20.0",
41
42
  "@lexical/utils": "^0.20.0",
42
43
  "@inshapardaz/likhari-converters": "^0.1.0",
43
44
  "@inshapardaz/likhari-core": "^0.1.0",