@inshapardaz/likhari-react 0.1.18-dev.158 → 0.1.18-dev.165

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.
@@ -1,7 +1,7 @@
1
1
  import { $findMatchingParent } from '@lexical/utils';
2
- import { $createParagraphNode, $getSelection, $isElementNode, $isParagraphNode, $isRangeSelection } from 'lexical';
2
+ import { $createParagraphNode, $getSelection, $isElementNode, $isParagraphNode, $isRangeSelection, } from 'lexical';
3
3
  import { $createLayoutContainerNode, $createLayoutItemNode, $isLayoutContainerNode, $isLayoutItemNode } from './LayoutNode.js';
4
- import { $createPoetryBlockNode, $isPoetryBlockNode, POETRY_SPACINGS, } from './PoetryNode.js';
4
+ import { $createPoetryBlockNode, $isPoetryBlockNode, POETRY_GUTTERS, POETRY_SPACINGS, POETRY_STAGGERS, } from './PoetryNode.js';
5
5
  /**
6
6
  * A poetry block is a *section* of one or more couplets sharing one layout
7
7
  * (single-column stacks each couplet's two misras directly as paragraph
@@ -11,7 +11,7 @@ import { $createPoetryBlockNode, $isPoetryBlockNode, POETRY_SPACINGS, } from './
11
11
  * read either shape, so nothing else needs to.
12
12
  */
13
13
  export function $getCouplets(node) {
14
- if (node.getLayout() === 'single') {
14
+ if (node.getLayout() !== 'two-column') {
15
15
  const paragraphs = node.getChildren().filter($isParagraphNode);
16
16
  const couplets = [];
17
17
  for (let i = 0; i + 1 < paragraphs.length; i += 2)
@@ -22,6 +22,12 @@ export function $getCouplets(node) {
22
22
  const couplets = [];
23
23
  for (const container of containers) {
24
24
  const items = container.getChildren().filter($isLayoutItemNode);
25
+ if (items.length === 1) {
26
+ const [a, b] = items[0].getChildren();
27
+ if ($isParagraphNode(a) && $isParagraphNode(b))
28
+ couplets.push([a, b]);
29
+ continue;
30
+ }
25
31
  const a = items[0]?.getFirstChild();
26
32
  const b = items[1]?.getFirstChild();
27
33
  if ($isParagraphNode(a) && $isParagraphNode(b))
@@ -58,7 +64,7 @@ function $findCoupletPosition(couplets, target) {
58
64
  function $appendCoupletTo(node) {
59
65
  const misraA = $createParagraphNode();
60
66
  const misraB = $createParagraphNode();
61
- if (node.getLayout() === 'single') {
67
+ if (node.getLayout() !== 'two-column') {
62
68
  node.append(misraA, misraB);
63
69
  }
64
70
  else {
@@ -80,7 +86,7 @@ function $appendCoupletTo(node) {
80
86
  function $insertCoupletRelativeTo(existingFirstMisra, layout, position) {
81
87
  const misraA = $createParagraphNode();
82
88
  const misraB = $createParagraphNode();
83
- if (layout === 'single') {
89
+ if (layout !== 'two-column') {
84
90
  if (position === 'before') {
85
91
  existingFirstMisra.insertBefore(misraA);
86
92
  misraA.insertAfter(misraB);
@@ -143,7 +149,7 @@ export function $setPoetryLayout(node, layout) {
143
149
  }
144
150
  for (const leftover of node.getChildren())
145
151
  leftover.remove();
146
- if (layout === 'single') {
152
+ if (layout !== 'two-column') {
147
153
  for (const [a, b] of couplets)
148
154
  node.append(a, b);
149
155
  }
@@ -174,10 +180,10 @@ export function $ensureTrailingParagraph(node) {
174
180
  * semantics, in its existing layout: "one poetry block can contain one or
175
181
  * more couplets, in one or two column layout", so a block's layout is
176
182
  * fixed once it has couplets, not chosen per couplet) and focuses its first
177
- * misra. Otherwise creates a new block (in `layout`/`align`) with one
183
+ * misra. Otherwise creates a new block (in `layout`) with one
178
184
  * couplet, after the selection's top-level element.
179
185
  */
180
- export function $insertPoetryCouplet(layout, align) {
186
+ export function $insertPoetryCouplet(layout) {
181
187
  const selection = $getSelection();
182
188
  if (!$isRangeSelection(selection))
183
189
  return false;
@@ -186,7 +192,7 @@ export function $insertPoetryCouplet(layout, align) {
186
192
  return $insertCoupletRelativeToSelection('after');
187
193
  }
188
194
  const anchorTopLevel = selection.anchor.getNode().getTopLevelElementOrThrow();
189
- const node = $createPoetryBlockNode(layout, align);
195
+ const node = $createPoetryBlockNode(layout);
190
196
  const [misraA] = $appendCoupletTo(node);
191
197
  anchorTopLevel.insertAfter(node);
192
198
  $ensureTrailingParagraph(node);
@@ -223,16 +229,23 @@ export function $deletePoetryCouplet() {
223
229
  }
224
230
  return true;
225
231
  }
232
+ /** Whether a point sits at the very start (or, with `atEnd`, very end) of a paragraph. */
233
+ function $isPointAt(paragraph, point, atEnd) {
234
+ if (point.key === paragraph.getKey())
235
+ return point.offset === (atEnd ? paragraph.getChildrenSize() : 0);
236
+ const edge = atEnd ? paragraph.getLastDescendant() : paragraph.getFirstDescendant();
237
+ if (point.getNode() !== edge)
238
+ return false;
239
+ return point.offset === (atEnd ? point.getNode().getTextContent().length : 0);
240
+ }
226
241
  /**
227
- * Backspace at the very start of an empty couplet removes just that
228
- * couplet — merging back into the previous one's end, or (if it's the
229
- * block's only couplet) removing the whole block, since an empty poetry
230
- * section serves no purpose. Otherwise there was no way to get rid of an
231
- * empty couplet created by mistake (its misra paragraphs each have their
232
- * own default canBeEmpty, so Lexical's usual "delete the empty block"
233
- * handling never reaches the couplet itself). Returns false for every
234
- * other caret position, including a non-empty couplet, so normal Backspace
235
- * handling (e.g. deleting within text) proceeds.
242
+ * Backspace at the start of a misra never merges it into its neighbour: the
243
+ * block's lines are paired into couplets, so merging one line away would
244
+ * shift every couplet after it (and take the previous couplet's last line
245
+ * with it). Instead, Backspace at the start of an empty couplet removes that
246
+ * couplet, and at the start of any other line it moves the caret to the end
247
+ * of the previous line. Other caret positions (deleting within text) fall
248
+ * through to normal handling.
236
249
  */
237
250
  export function $deletePoetryOnBackspace() {
238
251
  const selection = $getSelection();
@@ -242,35 +255,60 @@ export function $deletePoetryOnBackspace() {
242
255
  if (!block)
243
256
  return false;
244
257
  const couplets = $getCouplets(block);
245
- const anchorNode = selection.anchor.getNode();
246
- const position = $findCoupletPosition(couplets, anchorNode);
247
- if (!position || position.misraIndex !== 0)
248
- return false;
249
- const [firstMisra] = couplets[position.coupletIndex];
250
- const anchor = selection.anchor;
251
- const isAtStart = anchor.key === firstMisra.getKey() && anchor.offset === 0;
252
- if (!isAtStart)
258
+ const position = $findCoupletPosition(couplets, selection.anchor.getNode());
259
+ if (!position)
253
260
  return false;
254
- const [a, b] = couplets[position.coupletIndex];
255
- if (!a.isEmpty() || !b.isEmpty())
261
+ const { coupletIndex, misraIndex } = position;
262
+ const [a, b] = couplets[coupletIndex];
263
+ const misra = couplets[coupletIndex][misraIndex];
264
+ if (!$isPointAt(misra, selection.anchor, false))
256
265
  return false;
257
- if (couplets.length <= 1) {
258
- const previous = block.getPreviousSibling();
259
- block.remove();
260
- if ($isElementNode(previous))
261
- previous.selectEnd();
266
+ if (a.isEmpty() && b.isEmpty()) {
267
+ if (couplets.length <= 1) {
268
+ const previous = block.getPreviousSibling();
269
+ block.remove();
270
+ if ($isElementNode(previous))
271
+ previous.selectEnd();
272
+ return true;
273
+ }
274
+ const container = $findMatchingParent(a, $isLayoutContainerNode);
275
+ if (container)
276
+ container.remove();
277
+ else {
278
+ a.remove();
279
+ b.remove();
280
+ }
281
+ const remaining = $getCouplets(block);
282
+ const previousCouplet = remaining[coupletIndex - 1] ?? remaining[0];
283
+ previousCouplet?.[1].selectEnd();
262
284
  return true;
263
285
  }
264
- const container = $findMatchingParent(a, $isLayoutContainerNode);
265
- if (container)
266
- container.remove();
267
- else {
268
- a.remove();
269
- b.remove();
270
- }
271
- const remaining = $getCouplets(block);
272
- const previousCouplet = remaining[position.coupletIndex - 1] ?? remaining[0];
273
- previousCouplet?.[1].selectEnd();
286
+ const previousLine = misraIndex === 1 ? a : couplets[coupletIndex - 1]?.[1];
287
+ previousLine?.selectEnd();
288
+ return true;
289
+ }
290
+ /**
291
+ * Forward Delete at the end of a misra never merges the next line into it, for
292
+ * the same reason as $deletePoetryOnBackspace: the caret moves to the start of
293
+ * the next line instead. Other caret positions fall through to normal handling.
294
+ */
295
+ export function $deletePoetryForward() {
296
+ const selection = $getSelection();
297
+ if (!$isRangeSelection(selection) || !selection.isCollapsed())
298
+ return false;
299
+ const block = $getPoetryBlockFromSelection();
300
+ if (!block)
301
+ return false;
302
+ const couplets = $getCouplets(block);
303
+ const position = $findCoupletPosition(couplets, selection.anchor.getNode());
304
+ if (!position)
305
+ return false;
306
+ const { coupletIndex, misraIndex } = position;
307
+ const misra = couplets[coupletIndex][misraIndex];
308
+ if (!$isPointAt(misra, selection.anchor, true))
309
+ return false;
310
+ const nextLine = misraIndex === 0 ? couplets[coupletIndex][1] : couplets[coupletIndex + 1]?.[0];
311
+ nextLine?.selectStart();
274
312
  return true;
275
313
  }
276
314
  /**
@@ -347,3 +385,97 @@ export function $adjustPoetrySpacing(step) {
347
385
  block.setSpacing(next);
348
386
  return true;
349
387
  }
388
+ /** Steps the caret's two-column gutter one notch wider (+1) or narrower (-1);
389
+ * clamps at either end. */
390
+ export function $adjustPoetryGutter(step) {
391
+ const block = $getPoetryBlockFromSelection();
392
+ if (!block)
393
+ return false;
394
+ const index = POETRY_GUTTERS.indexOf(block.getGutter());
395
+ const next = POETRY_GUTTERS[Math.min(POETRY_GUTTERS.length - 1, Math.max(0, index + step))];
396
+ block.setGutter(next);
397
+ return true;
398
+ }
399
+ /** Steps the caret's staggered couplet width one notch narrower (-1) or wider
400
+ * (+1); clamps at either end. */
401
+ export function $adjustPoetryStagger(step) {
402
+ const block = $getPoetryBlockFromSelection();
403
+ if (!block)
404
+ return false;
405
+ const index = POETRY_STAGGERS.indexOf(block.getStagger());
406
+ const next = POETRY_STAGGERS[Math.min(POETRY_STAGGERS.length - 1, Math.max(0, index + step))];
407
+ block.setStagger(next);
408
+ return true;
409
+ }
410
+ /** A centered couplet is a two-column row holding both misras in one item
411
+ * (a single-item row), rather than one item per misra. */
412
+ function $isCoupletRowCentered(row) {
413
+ return row.getChildren().filter($isLayoutItemNode).length === 1;
414
+ }
415
+ function $findCoupletRow(couplet) {
416
+ return $findMatchingParent(couplet[0], $isLayoutContainerNode);
417
+ }
418
+ /** Whether the couplet the caret is in is centered, or null outside a
419
+ * two-column poetry block (centering only applies there). */
420
+ export function $getCoupletCenteredFromSelection() {
421
+ const block = $getPoetryBlockFromSelection();
422
+ if (!block || block.getLayout() !== 'two-column')
423
+ return null;
424
+ const selection = $getSelection();
425
+ if (!$isRangeSelection(selection))
426
+ return null;
427
+ const couplets = $getCouplets(block);
428
+ const position = $findCoupletPosition(couplets, selection.anchor.getNode());
429
+ if (!position)
430
+ return null;
431
+ const row = $findCoupletRow(couplets[position.coupletIndex]);
432
+ return row ? $isCoupletRowCentered(row) : false;
433
+ }
434
+ /** Centers (or un-centers) the caret's couplet within a two-column block by
435
+ * rebuilding its row around the same two misra paragraphs. */
436
+ export function $setCoupletCentered(centered) {
437
+ const block = $getPoetryBlockFromSelection();
438
+ if (!block || block.getLayout() !== 'two-column')
439
+ return false;
440
+ const selection = $getSelection();
441
+ if (!$isRangeSelection(selection))
442
+ return false;
443
+ const couplets = $getCouplets(block);
444
+ const position = $findCoupletPosition(couplets, selection.anchor.getNode());
445
+ if (!position)
446
+ return false;
447
+ const couplet = couplets[position.coupletIndex];
448
+ const row = $findCoupletRow(couplet);
449
+ if (!row || $isCoupletRowCentered(row) === centered)
450
+ return true;
451
+ const [a, b] = couplet;
452
+ const newRow = $createLayoutContainerNode(centered ? '1fr' : 'repeat(2, 1fr)');
453
+ if (centered) {
454
+ newRow.append($createLayoutItemNode().append(a, b));
455
+ }
456
+ else {
457
+ newRow.append($createLayoutItemNode().append(a), $createLayoutItemNode().append(b));
458
+ }
459
+ row.insertAfter(newRow);
460
+ row.remove();
461
+ a.selectStart();
462
+ return true;
463
+ }
464
+ /** Removes the whole poetry block the caret is in, including every couplet. */
465
+ export function $deletePoetryBlock() {
466
+ const block = $getPoetryBlockFromSelection();
467
+ if (!block)
468
+ return false;
469
+ block.remove();
470
+ return true;
471
+ }
472
+ /** Single-column blocks pair their paragraphs into couplets, so a line left
473
+ * without a partner (from pasted or imported content) would be invisible to
474
+ * the couplet logic. Give it an empty partner instead, so every line belongs
475
+ * to a couplet and can be deleted like any other. */
476
+ export function $completeSingleCoupletBlock(node) {
477
+ if (node.getLayout() === 'two-column')
478
+ return;
479
+ if (node.getChildrenSize() % 2 === 1)
480
+ node.append($createParagraphNode());
481
+ }
@@ -0,0 +1,8 @@
1
+ import type { Strings } from '../i18n/strings.js';
2
+ /** Find-and-replace controls shown above the canvas. Matches are recomputed
3
+ * from the document on every action, so edits made in between are respected. */
4
+ export declare function FindReplaceBar({ strings, onClose }: {
5
+ strings: Strings;
6
+ onClose: () => void;
7
+ }): import("react").JSX.Element;
8
+ //# sourceMappingURL=FindReplaceBar.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FindReplaceBar.d.ts","sourceRoot":"","sources":["../../src/components/FindReplaceBar.tsx"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,iBAAiB,CAAC;AAG/C;gFACgF;AAChF,wBAAgB,cAAc,CAAC,EAAE,OAAO,EAAE,OAAO,EAAE,EAAE;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,OAAO,EAAE,MAAM,IAAI,CAAA;CAAE,+BA2F7F"}
@@ -0,0 +1,57 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useState } from 'react';
3
+ import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
4
+ import { IconX } from '@tabler/icons-react';
5
+ import { $findMatches, $replaceAll, $replaceMatch, $selectMatch } from '../find/findReplaceActions.js';
6
+ /** Find-and-replace controls shown above the canvas. Matches are recomputed
7
+ * from the document on every action, so edits made in between are respected. */
8
+ export function FindReplaceBar({ strings, onClose }) {
9
+ const [editor] = useLexicalComposerContext();
10
+ const t = strings.findReplace;
11
+ const [query, setQuery] = useState('');
12
+ const [replacement, setReplacement] = useState('');
13
+ const [index, setIndex] = useState(0);
14
+ const [count, setCount] = useState(0);
15
+ // Runs `action` against the current matches and returns the new count, then
16
+ // syncs the index and selection. `discrete` makes the update commit before
17
+ // the next read, so the count reflects this action's changes.
18
+ const run = (action) => {
19
+ let result = { nextIndex: 0, matches: [] };
20
+ editor.update(() => {
21
+ result = action($findMatches(query));
22
+ if (result.matches[result.nextIndex])
23
+ $selectMatch(result.matches[result.nextIndex]);
24
+ }, { discrete: true });
25
+ setCount(result.matches.length);
26
+ setIndex(result.nextIndex);
27
+ const anchor = result.matches[result.nextIndex];
28
+ if (anchor)
29
+ editor.getElementByKey(anchor.anchorKey)?.scrollIntoView({ block: 'center' });
30
+ };
31
+ useEffect(() => {
32
+ run((matches) => ({ nextIndex: 0, matches }));
33
+ // Re-run when the query changes; `run` closes over it.
34
+ }, [query]);
35
+ const step = (delta) => run((matches) => ({
36
+ matches,
37
+ nextIndex: matches.length === 0 ? 0 : (index + delta + matches.length) % matches.length,
38
+ }));
39
+ const replaceCurrent = () => run((matches) => {
40
+ const match = matches[index];
41
+ if (match)
42
+ $replaceMatch(match, replacement);
43
+ const after = $findMatches(query);
44
+ return { matches: after, nextIndex: Math.min(index, Math.max(0, after.length - 1)) };
45
+ });
46
+ const replaceEverything = () => run(() => {
47
+ $replaceAll(query, replacement);
48
+ return { matches: $findMatches(query), nextIndex: 0 };
49
+ });
50
+ const status = query === '' ? '' : count === 0 ? t.noMatches : `${index + 1} / ${count}`;
51
+ return (_jsxs("div", { className: "likhari-find-bar", role: "search", children: [_jsx("input", { "aria-label": t.find, placeholder: t.findPlaceholder, value: query, onChange: (e) => setQuery(e.target.value), onKeyDown: (e) => {
52
+ if (e.key === 'Enter')
53
+ step(e.shiftKey ? -1 : 1);
54
+ if (e.key === 'Escape')
55
+ onClose();
56
+ } }), _jsx("span", { className: "likhari-find-status", "aria-live": "polite", children: status }), _jsx("button", { type: "button", onClick: () => step(-1), disabled: count === 0, children: t.previous }), _jsx("button", { type: "button", onClick: () => step(1), disabled: count === 0, children: t.next }), _jsx("input", { "aria-label": t.replace, placeholder: t.replacePlaceholder, value: replacement, onChange: (e) => setReplacement(e.target.value) }), _jsx("button", { type: "button", onClick: replaceCurrent, disabled: count === 0, children: t.replaceOne }), _jsx("button", { type: "button", onClick: replaceEverything, disabled: count === 0, children: t.replaceAll }), _jsx("button", { type: "button", className: "likhari-find-close", onClick: onClose, "aria-label": t.close, title: t.close, children: _jsx(IconX, { size: 16, stroke: 1.75 }) })] }));
57
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"AAuEA,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;AAsfvD,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,+BA+jCxJ"}
1
+ {"version":3,"file":"Toolbar.d.ts","sourceRoot":"","sources":["../../src/components/Toolbar.tsx"],"names":[],"mappings":"AAsFA,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;AA+gBvD,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,+BAwkCxJ"}
@@ -8,14 +8,15 @@ import { $createParagraphNode } from 'lexical';
8
8
  import { $createHeadingNode, $createQuoteNode, $isHeadingNode, $isQuoteNode } from '@lexical/rich-text';
9
9
  import { $isListNode, INSERT_CHECK_LIST_COMMAND, INSERT_ORDERED_LIST_COMMAND, INSERT_UNORDERED_LIST_COMMAND, REMOVE_LIST_COMMAND, } from '@lexical/list';
10
10
  import { $findMatchingParent, $insertNodeToNearestRoot } from '@lexical/utils';
11
+ import { FindReplaceBar } from './FindReplaceBar.js';
11
12
  import { INSERT_HORIZONTAL_RULE_COMMAND } from '@lexical/react/LexicalHorizontalRuleNode';
12
13
  import { ImageDialog } from '../image/ImageDialog.js';
13
14
  import { $createImageNode } from '../image/ImageNode.js';
14
15
  import { INSERT_PAGE_BREAK_COMMAND } from '../blocks/PageBreakNode.js';
15
16
  import { INSERT_LAYOUT_COMMAND } from '../blocks/LayoutNode.js';
16
17
  import { INSERT_FOOTNOTE_COMMAND } from '../blocks/FootnoteNode.js';
17
- import { $isPoetryBlockNode } from '../blocks/PoetryNode.js';
18
- import { $adjustPoetrySpacing, $deletePoetryCouplet, $getPoetryBlockFromSelection, $insertCoupletRelativeToSelection, $setPoetryLayout, } from '../blocks/poetryActions.js';
18
+ import { $isPoetryBlockNode, POETRY_GUTTERS, POETRY_SPACINGS, POETRY_STAGGERS, } from '../blocks/PoetryNode.js';
19
+ import { $adjustPoetryGutter, $getCoupletCenteredFromSelection, $setCoupletCentered, $adjustPoetryStagger, $adjustPoetrySpacing, $deletePoetryBlock, $deletePoetryCouplet, $getPoetryBlockFromSelection, $insertCoupletRelativeToSelection, $setPoetryLayout, } from '../blocks/poetryActions.js';
19
20
  import { INSERT_POETRY_COUPLET_COMMAND } from '../plugins/PoetryPlugin.js';
20
21
  import { TableDialog } from './TableDialog.js';
21
22
  import { LayoutDialog } from './LayoutDialog.js';
@@ -29,7 +30,7 @@ import { CANVAS_FONT_DEFAULTS, DEFAULT_FONT_OPTIONS, FONT_SIZES_PX } from '../fo
29
30
  import { setStyleProperty } from '../utils/style.js';
30
31
  import { useStrings } from '../i18n/useStrings.js';
31
32
  import { usePortalTarget } from '../PortalTargetContext.js';
32
- import { IconAbc, IconAlignCenter, IconAlignJustified, IconAlignLeft, IconAlignRight, IconArrowBackUp, IconArrowForwardUp, IconBold, IconClearFormatting, IconDeviceFloppy, IconDots, IconHistory, IconExternalLink, IconFeather, IconH1, IconH2, IconH3, IconH4, IconH5, IconH6, IconIndentDecrease, IconIndentIncrease, IconItalic, IconLetterCase, IconLetterCaseLower, IconLetterCaseUpper, IconLink, IconLinkOff, IconList, IconListCheck, IconListNumbers, IconPageBreak, IconPencil, IconPhoto, IconPilcrow, IconQuote, IconSeparatorHorizontal, IconSparkles, IconStrikethrough, IconSubscript, IconSuperscript, IconArrowMergeBoth, IconArrowsSplit2, IconColumnInsertLeft, IconColumnInsertRight, IconColumnRemove, IconColumns, IconColumns1, IconColumns2, IconNumber1Small, IconRowInsertBottom, IconRowInsertTop, IconRowRemove, IconArrowsMaximize, IconArrowsMinimize, IconTable, IconTableMinus, IconTrash, IconTableOptions, IconTextSize, IconTypography, IconUnderline, IconWand, } from '@tabler/icons-react';
33
+ import { IconAbc, IconAlignCenter, IconAlignJustified, IconAlignLeft, IconAlignRight, IconArrowBackUp, IconArrowForwardUp, IconBold, IconClearFormatting, IconDeviceFloppy, IconDots, IconHistory, IconExternalLink, IconFeather, IconH1, IconH2, IconH3, IconH4, IconH5, IconH6, IconIndentDecrease, IconIndentIncrease, IconItalic, IconLetterCase, IconLetterCaseLower, IconLetterCaseUpper, IconLink, IconLinkOff, IconList, IconListCheck, IconListNumbers, IconPageBreak, IconPencil, IconPhoto, IconPilcrow, IconQuote, IconSeparatorHorizontal, IconSparkles, IconStrikethrough, IconSubscript, IconSuperscript, IconArrowMergeBoth, IconArrowsSplit2, IconColumnInsertLeft, IconColumnInsertRight, IconColumnRemove, IconColumns, IconSearch, IconColumns1, IconColumns2, IconNumber1Small, IconRowInsertBottom, IconRowInsertTop, IconRowRemove, IconArrowsMaximize, IconArrowsMinimize, IconArrowsShuffle, IconTable, IconTableMinus, IconTrash, IconTableOptions, IconTextSize, IconTypography, IconUnderline, IconWand, } from '@tabler/icons-react';
33
34
  /** Sentinel for "no inline style": Lexical returns the default it is given when
34
35
  * the selection has none, so this tells "unstyled" (show the canvas default)
35
36
  * apart from "styled with a value the dropdown doesn't list" (show nothing). */
@@ -50,7 +51,8 @@ const INITIAL_STATE = {
50
51
  canUnmergeCell: false,
51
52
  inPoetry: false,
52
53
  poetryLayout: 'single',
53
- poetryAlign: 'justify',
54
+ poetryScale: { spacing: 'normal', gutter: 'normal', stagger: 'normal' },
55
+ poetryCentered: false,
54
56
  canUndo: false,
55
57
  canRedo: false,
56
58
  };
@@ -162,16 +164,14 @@ function TableMenuItems({ strings, direction, rows, columns, canMergeCells, canU
162
164
  };
163
165
  return (_jsxs(_Fragment, { children: [_jsx(Menu.Label, { children: t.menuLabel }), item(IconRowInsertTop, manyRows ? t.insertRowsBefore : t.insertRowBefore, () => $insertTableRows(false)), item(IconRowInsertBottom, manyRows ? t.insertRowsAfter : t.insertRowAfter, () => $insertTableRows(true)), item(rtl ? IconColumnInsertRight : IconColumnInsertLeft, manyColumns ? t.insertColumnsBefore : t.insertColumnBefore, () => $insertTableColumns(false)), item(rtl ? IconColumnInsertLeft : IconColumnInsertRight, manyColumns ? t.insertColumnsAfter : t.insertColumnAfter, () => $insertTableColumns(true)), (canMergeCells || canUnmergeCell) && _jsx(Menu.Divider, {}), canMergeCells && item(IconArrowMergeBoth, t.mergeCells, $mergeTableCells), canUnmergeCell && item(IconArrowsSplit2, t.unmergeCell, $unmergeTableCell), _jsx(Menu.Divider, {}), item(IconRowRemove, manyRows ? t.deleteRows : t.deleteRow, $deleteTableRows), item(IconColumnRemove, manyColumns ? t.deleteColumns : t.deleteColumn, $deleteTableColumns), item(IconTableMinus, t.deleteTable, $deleteTable, 'red')] }));
164
166
  }
165
- /** The poetry couplet menu's contents — layout (single/two-column) and
166
- * literal alignment, both per-instance overrides (requirements doc §4.11:
167
- * "set per CoupletNode, not inherited from a document-wide setting"), plus
168
- * table-row-style structural actions (insert a couplet above/below the one
169
- * the caret is in, delete it) — a poetry block is "one object with multiple
170
- * couplets, add/remove like table rows", so these mirror TableMenuItems'
171
- * own insert-before/insert-after/delete-row actions. */
172
- function PoetryMenuItems({ strings, alignIcons, layout, align, onAction, }) {
167
+ /** Whether a one-notch step from `value` stays on the scale — a step that
168
+ * would fall off either end is disabled rather than silently clamped. */
169
+ function canStep(scale, value, step) {
170
+ const next = scale.indexOf(value) + step;
171
+ return next >= 0 && next < scale.length;
172
+ }
173
+ function PoetryMenuItems({ strings, layout, scale, centered, onAction, }) {
173
174
  const t = strings.poetryMenu;
174
- const a = strings.toolbar.alignOptions;
175
175
  const item = (icon, label, action, active = false, color) => {
176
176
  const Icon = icon;
177
177
  return (_jsx(Menu.Item, { color: color, disabled: active, leftSection: _jsx(Icon, { size: ICON_SIZE, stroke: ICON_STROKE }), onMouseDown: (e) => e.preventDefault(), onClick: onAction(action), children: label }));
@@ -181,10 +181,7 @@ function PoetryMenuItems({ strings, alignIcons, layout, align, onAction, }) {
181
181
  if (node)
182
182
  $setPoetryLayout(node, next);
183
183
  };
184
- const setAlign = (next) => () => {
185
- $getPoetryBlockFromSelection()?.setAlign(next);
186
- };
187
- return (_jsxs(_Fragment, { children: [_jsx(Menu.Label, { children: t.menuLabel }), item(IconColumns1, t.singleColumn, setLayout('single'), layout === 'single'), item(IconColumns2, t.twoColumn, setLayout('two-column'), layout === 'two-column'), _jsx(Menu.Divider, {}), item(alignIcons.justify ?? IconAlignJustified, a.justify, setAlign('justify'), align === 'justify'), item(alignIcons.start ?? IconAlignLeft, a.start, setAlign('start'), align === 'start'), item(IconAlignLeft, a.left, setAlign('left'), align === 'left'), item(IconAlignRight, a.right, setAlign('right'), align === 'right'), _jsx(Menu.Divider, {}), item(IconRowInsertTop, t.insertCoupletBefore, () => $insertCoupletRelativeToSelection('before')), item(IconRowInsertBottom, t.insertCoupletAfter, () => $insertCoupletRelativeToSelection('after')), item(IconArrowsMinimize, t.tighterSpacing, () => $adjustPoetrySpacing(-1)), item(IconArrowsMaximize, t.looserSpacing, () => $adjustPoetrySpacing(1)), item(IconTrash, t.deleteCouplet, $deletePoetryCouplet, false, 'red')] }));
184
+ return (_jsxs(_Fragment, { children: [_jsx(Menu.Label, { children: t.menuLabel }), item(IconColumns1, t.singleColumn, setLayout('single'), layout === 'single'), item(IconColumns2, t.twoColumn, setLayout('two-column'), layout === 'two-column'), item(IconArrowsShuffle, t.staggered, setLayout('staggered'), layout === 'staggered'), _jsx(Menu.Divider, {}), _jsx(Menu.Divider, {}), item(IconRowInsertTop, t.insertCoupletBefore, () => $insertCoupletRelativeToSelection('before')), item(IconRowInsertBottom, t.insertCoupletAfter, () => $insertCoupletRelativeToSelection('after')), item(IconArrowsMinimize, t.tighterSpacing, () => $adjustPoetrySpacing(-1), !canStep(POETRY_SPACINGS, scale.spacing, -1)), item(IconArrowsMaximize, t.looserSpacing, () => $adjustPoetrySpacing(1), !canStep(POETRY_SPACINGS, scale.spacing, 1)), layout === 'staggered' && (_jsxs(_Fragment, { children: [item(IconArrowsMinimize, t.narrowerCouplets, () => $adjustPoetryStagger(-1), !canStep(POETRY_STAGGERS, scale.stagger, -1)), item(IconArrowsMaximize, t.widerCouplets, () => $adjustPoetryStagger(1), !canStep(POETRY_STAGGERS, scale.stagger, 1))] })), layout === 'two-column' && (_jsxs(_Fragment, { children: [item(IconAlignCenter, centered ? t.uncenterCouplet : t.centerCouplet, () => $setCoupletCentered(!centered)), item(IconColumns2, t.narrowerGutter, () => $adjustPoetryGutter(-1), !canStep(POETRY_GUTTERS, scale.gutter, -1)), item(IconColumns2, t.widerGutter, () => $adjustPoetryGutter(1), !canStep(POETRY_GUTTERS, scale.gutter, 1))] })), item(IconTrash, t.deleteCouplet, $deletePoetryCouplet, false, 'red'), item(IconTrash, t.deletePoetry, $deletePoetryBlock, false, 'red')] }));
188
185
  }
189
186
  export function Toolbar({ config, onSave, isDirty, showSave, fontOptions = DEFAULT_FONT_OPTIONS, direction = 'ltr', locale = 'en', drafts }) {
190
187
  const [editor] = useLexicalComposerContext();
@@ -246,7 +243,10 @@ export function Toolbar({ config, onSave, isDirty, showSave, fontOptions = DEFAU
246
243
  // node method (getFormatType() included) must not be deferred into it.
247
244
  const elementFormat = ($isElementNode(element) ? element.getFormatType() : 'start') || 'start';
248
245
  const poetryLayout = poetryBlock?.getLayout();
249
- const poetryAlign = poetryBlock?.getAlign();
246
+ const poetryCentered = $getCoupletCenteredFromSelection();
247
+ const poetryScale = poetryBlock
248
+ ? { spacing: poetryBlock.getSpacing(), gutter: poetryBlock.getGutter(), stagger: poetryBlock.getStagger() }
249
+ : null;
250
250
  setState((s) => ({
251
251
  ...s,
252
252
  blockType,
@@ -264,7 +264,8 @@ export function Toolbar({ config, onSave, isDirty, showSave, fontOptions = DEFAU
264
264
  canUnmergeCell,
265
265
  inPoetry: poetryBlock !== null,
266
266
  poetryLayout: poetryLayout ?? s.poetryLayout,
267
- poetryAlign: poetryAlign ?? s.poetryAlign,
267
+ poetryScale: poetryScale ?? s.poetryScale,
268
+ poetryCentered: poetryCentered ?? false,
268
269
  }));
269
270
  });
270
271
  }, [editor]);
@@ -385,6 +386,7 @@ export function Toolbar({ config, onSave, isDirty, showSave, fontOptions = DEFAU
385
386
  closeTableDialog();
386
387
  };
387
388
  const [layoutDialogOpen, setLayoutDialogOpen] = useState(false);
389
+ const [findOpen, setFindOpen] = useState(false);
388
390
  const openLayoutDialog = () => {
389
391
  snapshotSelection();
390
392
  setLayoutDialogOpen(true);
@@ -808,9 +810,9 @@ export function Toolbar({ config, onSave, isDirty, showSave, fontOptions = DEFAU
808
810
  restoreSelection();
809
811
  openLinkDialog();
810
812
  }, onRemove: removeLink, strings: strings }) })] })) : (_jsx(ToolbarButton, { icon: IconLink, title: strings.toolbar.insertLink, onClick: openLinkDialog })) }, "link")),
811
- showStubInsertGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [(config.images.linked || config.images.embedded) && (_jsx(ToolbarButton, { icon: IconPhoto, title: strings.toolbar.insertImage, onClick: openImageDialog })), config.blocks.pageBreak && (_jsx(ToolbarButton, { icon: IconPageBreak, title: strings.toolbar.insertPageBreak, onClick: () => editor.dispatchCommand(INSERT_PAGE_BREAK_COMMAND, undefined) })), config.tables && _jsx(ToolbarButton, { icon: IconTable, title: strings.toolbar.insertTable, onClick: openTableDialog }), config.columns && _jsx(ToolbarButton, { icon: IconColumns, title: strings.toolbar.insertColumns, onClick: openLayoutDialog }), config.footnotes && (_jsx(ToolbarButton, { icon: IconNumber1Small, title: strings.toolbar.insertFootnote, onClick: () => editor.dispatchCommand(INSERT_FOOTNOTE_COMMAND, undefined) })), config.blocks.horizontalRule && (_jsx(ToolbarButton, { icon: IconSeparatorHorizontal, title: strings.toolbar.insertHorizontalRule, onClick: () => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined) })), config.poetry.enabled && locale !== 'en' && (_jsx(ToolbarButton, { icon: IconFeather, title: strings.toolbar.insertPoetryCouplet, onClick: () => editor.dispatchCommand(INSERT_POETRY_COUPLET_COMMAND, { layout: config.poetry.defaultLayout, align: 'justify' }) }))] }, "stubInsert")),
813
+ showStubInsertGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [(config.images.linked || config.images.embedded) && (_jsx(ToolbarButton, { icon: IconPhoto, title: strings.toolbar.insertImage, onClick: openImageDialog })), config.blocks.pageBreak && (_jsx(ToolbarButton, { icon: IconPageBreak, title: strings.toolbar.insertPageBreak, onClick: () => editor.dispatchCommand(INSERT_PAGE_BREAK_COMMAND, undefined) })), config.tables && _jsx(ToolbarButton, { icon: IconTable, title: strings.toolbar.insertTable, onClick: openTableDialog }), config.columns && _jsx(ToolbarButton, { icon: IconColumns, title: strings.toolbar.insertColumns, onClick: openLayoutDialog }), config.footnotes && (_jsx(ToolbarButton, { icon: IconNumber1Small, title: strings.toolbar.insertFootnote, onClick: () => editor.dispatchCommand(INSERT_FOOTNOTE_COMMAND, undefined) })), config.blocks.horizontalRule && (_jsx(ToolbarButton, { icon: IconSeparatorHorizontal, title: strings.toolbar.insertHorizontalRule, onClick: () => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined) })), config.findReplace && (_jsx(ToolbarButton, { icon: IconSearch, title: strings.findReplace.toggle, active: findOpen, onClick: () => setFindOpen((v) => !v) })), config.poetry.enabled && locale !== 'en' && (_jsx(ToolbarButton, { icon: IconFeather, title: strings.toolbar.insertPoetryCouplet, onClick: () => editor.dispatchCommand(INSERT_POETRY_COUPLET_COMMAND, { layout: config.poetry.defaultLayout }) }))] }, "stubInsert")),
812
814
  config.tables && state.inTable && (_jsx("div", { className: "likhari-toolbar-group", children: _jsxs(Menu, { position: "bottom-start", withinPortal: true, portalProps: { target: portalTarget }, shadow: "sm", width: 240, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "data-active": "true", "aria-pressed": "true", "aria-haspopup": "menu", "aria-label": strings.toolbar.tableOptions, title: strings.toolbar.tableOptions, onMouseDown: (e) => e.preventDefault(), children: _jsx(IconTableOptions, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsx(Menu.Dropdown, { children: _jsx(TableMenuItems, { strings: strings, direction: direction, rows: state.tableRows, columns: state.tableColumns, canMergeCells: state.canMergeCells, canUnmergeCell: state.canUnmergeCell, onAction: runMenuAction }) })] }) }, "tableActions")),
813
- config.poetry.enabled && state.inPoetry && (_jsx("div", { className: "likhari-toolbar-group", children: _jsxs(Menu, { position: "bottom-start", withinPortal: true, portalProps: { target: portalTarget }, shadow: "sm", width: 200, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "data-active": "true", "aria-pressed": "true", "aria-haspopup": "menu", "aria-label": strings.toolbar.poetryOptions, title: strings.toolbar.poetryOptions, onMouseDown: (e) => e.preventDefault(), children: _jsx(IconFeather, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsx(Menu.Dropdown, { children: _jsx(PoetryMenuItems, { strings: strings, alignIcons: ALIGN_ICONS, layout: state.poetryLayout, align: state.poetryAlign, onAction: runMenuAction }) })] }) }, "poetryActions")),
815
+ config.poetry.enabled && state.inPoetry && (_jsx("div", { className: "likhari-toolbar-group", children: _jsxs(Menu, { position: "bottom-start", withinPortal: true, portalProps: { target: portalTarget }, shadow: "sm", width: 200, onOpen: snapshotSelection, children: [_jsx(Menu.Target, { children: _jsx("button", { type: "button", className: "likhari-toolbar-button", "data-active": "true", "aria-pressed": "true", "aria-haspopup": "menu", "aria-label": strings.toolbar.poetryOptions, title: strings.toolbar.poetryOptions, onMouseDown: (e) => e.preventDefault(), children: _jsx(IconFeather, { size: ICON_SIZE, stroke: ICON_STROKE }) }) }), _jsx(Menu.Dropdown, { children: _jsx(PoetryMenuItems, { strings: strings, layout: state.poetryLayout, scale: state.poetryScale, centered: state.poetryCentered, onAction: runMenuAction }) })] }) }, "poetryActions")),
814
816
  showLanguageGroup && (_jsxs("div", { className: "likhari-toolbar-group likhari-toolbar-group--collapse-tablet", children: [config.language.autocorrect && _jsx(StubButton, { icon: IconWand, title: strings.toolbar.autocorrect, comingSoon: strings.toolbar.comingSoon }), config.language.textCleanup && _jsx(StubButton, { icon: IconSparkles, title: strings.toolbar.textCleanup, comingSoon: strings.toolbar.comingSoon }), config.language.spellCheck && _jsx(StubButton, { icon: IconAbc, title: strings.toolbar.spellChecker, comingSoon: strings.toolbar.comingSoon })] }, "language")),
815
817
  ];
816
818
  return (_jsxs("div", { className: "likhari-toolbar", role: "toolbar", "aria-label": strings.toolbar.ariaLabel, ref: toolbarContainerRef, children: [fixedGroups, movableGroups
@@ -829,7 +831,7 @@ export function Toolbar({ config, onSave, isDirty, showSave, fontOptions = DEFAU
829
831
  }, position: "bottom-start", withinPortal: true, portalProps: { target: portalTarget }, shadow: "sm", width: 240, children: [_jsx(Menu.Target, { children: _jsx("span", { "aria-hidden": "true", style: { position: 'fixed', left: tableContext?.x ?? -9999, top: tableContext?.y ?? -9999, width: 1, height: 1, pointerEvents: 'none' } }) }), _jsx(Menu.Dropdown, { children: _jsx(TableMenuItems, { strings: strings, direction: direction, rows: tableContext?.rows ?? 1, columns: tableContext?.columns ?? 1, canMergeCells: state.canMergeCells, canUnmergeCell: state.canUnmergeCell, onAction: runMenuAction }) })] })), config.poetry.enabled && (_jsxs(Menu, { opened: poetryContext !== null, onChange: (opened) => {
830
832
  if (!opened)
831
833
  setPoetryContext(null);
832
- }, position: "bottom-start", withinPortal: true, portalProps: { target: portalTarget }, shadow: "sm", width: 200, children: [_jsx(Menu.Target, { children: _jsx("span", { "aria-hidden": "true", style: { position: 'fixed', left: poetryContext?.x ?? -9999, top: poetryContext?.y ?? -9999, width: 1, height: 1, pointerEvents: 'none' } }) }), _jsx(Menu.Dropdown, { children: _jsx(PoetryMenuItems, { strings: strings, alignIcons: ALIGN_ICONS, layout: state.poetryLayout, align: state.poetryAlign, onAction: runMenuAction }) })] })), _jsxs("div", { "aria-hidden": "true", ref: toolbarMeasureRef, style: {
834
+ }, position: "bottom-start", withinPortal: true, portalProps: { target: portalTarget }, shadow: "sm", width: 200, children: [_jsx(Menu.Target, { children: _jsx("span", { "aria-hidden": "true", style: { position: 'fixed', left: poetryContext?.x ?? -9999, top: poetryContext?.y ?? -9999, width: 1, height: 1, pointerEvents: 'none' } }) }), _jsx(Menu.Dropdown, { children: _jsx(PoetryMenuItems, { strings: strings, layout: state.poetryLayout, scale: state.poetryScale, centered: state.poetryCentered, onAction: runMenuAction }) })] })), _jsxs("div", { "aria-hidden": "true", ref: toolbarMeasureRef, style: {
833
835
  position: 'absolute',
834
836
  visibility: 'hidden',
835
837
  pointerEvents: 'none',
@@ -838,5 +840,5 @@ export function Toolbar({ config, onSave, isDirty, showSave, fontOptions = DEFAU
838
840
  display: 'flex',
839
841
  flexWrap: 'nowrap',
840
842
  gap: 8,
841
- }, children: [fixedGroups, movableGroups.map((group) => (_jsx(MovableGroupButtons, { group: group }, group.key)))] }), (config.images.linked || config.images.embedded) && (_jsx(ImageDialog, { mode: "insert", opened: imageDialogOpen, onSubmit: insertImage, onClose: closeImageDialog })), config.tables && _jsx(TableDialog, { opened: tableDialogOpen, onSubmit: insertTable, onClose: closeTableDialog }), config.columns && _jsx(LayoutDialog, { opened: layoutDialogOpen, onSubmit: insertLayout, onClose: closeLayoutDialog }), config.links && (_jsx(LinkDialog, { opened: linkDialogOpen, initialUrl: state.linkUrl, showTextField: linkNeedsText, onSubmit: applyLink, onRemove: state.isLink ? removeLink : undefined, onClose: closeLinkDialog })), drafts && _jsx(DraftsDialog, { opened: draftsOpen, locale: locale, onClose: () => setDraftsOpen(false), ...drafts })] }));
843
+ }, children: [fixedGroups, movableGroups.map((group) => (_jsx(MovableGroupButtons, { group: group }, group.key)))] }), (config.images.linked || config.images.embedded) && (_jsx(ImageDialog, { mode: "insert", opened: imageDialogOpen, onSubmit: insertImage, onClose: closeImageDialog })), config.tables && _jsx(TableDialog, { opened: tableDialogOpen, onSubmit: insertTable, onClose: closeTableDialog }), config.findReplace && findOpen && _jsx(FindReplaceBar, { strings: strings, onClose: () => setFindOpen(false) }), config.columns && _jsx(LayoutDialog, { opened: layoutDialogOpen, onSubmit: insertLayout, onClose: closeLayoutDialog }), config.links && (_jsx(LinkDialog, { opened: linkDialogOpen, initialUrl: state.linkUrl, showTextField: linkNeedsText, onSubmit: applyLink, onRemove: state.isLink ? removeLink : undefined, onClose: closeLinkDialog })), drafts && _jsx(DraftsDialog, { opened: draftsOpen, locale: locale, onClose: () => setDraftsOpen(false), ...drafts })] }));
842
844
  }
@@ -0,0 +1,23 @@
1
+ /** A match as a Lexical selection range: start and end points, each in a text node. */
2
+ export interface FindMatch {
3
+ anchorKey: string;
4
+ anchorOffset: number;
5
+ focusKey: string;
6
+ focusOffset: number;
7
+ }
8
+ /** Every non-overlapping occurrence of `query` in the document, case-sensitive. */
9
+ export declare function $findMatches(query: string): FindMatch[];
10
+ /** Makes the match the editor's selection, so it's highlighted and scrolled to. */
11
+ export declare function $selectMatch(match: FindMatch): void;
12
+ /**
13
+ * Replaces one match, keeping the formatting of the text around it. The
14
+ * replacement takes the formatting of the match's first character.
15
+ */
16
+ export declare function $replaceMatch(match: FindMatch, replacement: string): void;
17
+ /**
18
+ * Replaces every match. Walks from the last match to the first, so each
19
+ * replacement leaves the positions of the matches before it unchanged.
20
+ * Returns how many were replaced.
21
+ */
22
+ export declare function $replaceAll(query: string, replacement: string): number;
23
+ //# sourceMappingURL=findReplaceActions.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"findReplaceActions.d.ts","sourceRoot":"","sources":["../../src/find/findReplaceActions.ts"],"names":[],"mappings":"AAEA,uFAAuF;AACvF,MAAM,WAAW,SAAS;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,YAAY,EAAE,MAAM,CAAC;IACrB,QAAQ,EAAE,MAAM,CAAC;IACjB,WAAW,EAAE,MAAM,CAAC;CACrB;AAgCD,mFAAmF;AACnF,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,EAAE,CAcvD;AAED,mFAAmF;AACnF,wBAAgB,YAAY,CAAC,KAAK,EAAE,SAAS,GAAG,IAAI,CAKnD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,GAAG,IAAI,CAMzE;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,MAAM,CAItE"}
@@ -0,0 +1,81 @@
1
+ import { $createRangeSelection, $getRoot, $getSelection, $isRangeSelection, $setSelection } from 'lexical';
2
+ /**
3
+ * Text nodes grouped by their parent, in document order. A match is searched
4
+ * for within one parent only, so it never spans two paragraphs or table
5
+ * cells, but it can span the several text nodes that formatting splits a
6
+ * paragraph into.
7
+ */
8
+ function $textGroups() {
9
+ const groups = new Map();
10
+ for (const node of $getRoot().getAllTextNodes()) {
11
+ const parentKey = node.getParentOrThrow().getKey();
12
+ const group = groups.get(parentKey);
13
+ if (group)
14
+ group.push(node);
15
+ else
16
+ groups.set(parentKey, [node]);
17
+ }
18
+ return [...groups.values()];
19
+ }
20
+ /** The text node and offset that a character index in a group falls in. */
21
+ function $locate(group, index, end) {
22
+ let start = 0;
23
+ for (const node of group) {
24
+ const length = node.getTextContent().length;
25
+ const inside = end ? index > start && index <= start + length : index >= start && index < start + length;
26
+ if (inside)
27
+ return { key: node.getKey(), offset: index - start };
28
+ start += length;
29
+ }
30
+ const last = group[group.length - 1];
31
+ return { key: last.getKey(), offset: last.getTextContent().length };
32
+ }
33
+ /** Every non-overlapping occurrence of `query` in the document, case-sensitive. */
34
+ export function $findMatches(query) {
35
+ if (query === '')
36
+ return [];
37
+ const matches = [];
38
+ for (const group of $textGroups()) {
39
+ const text = group.map((node) => node.getTextContent()).join('');
40
+ let index = text.indexOf(query);
41
+ while (index !== -1) {
42
+ const start = $locate(group, index, false);
43
+ const end = $locate(group, index + query.length, true);
44
+ matches.push({ anchorKey: start.key, anchorOffset: start.offset, focusKey: end.key, focusOffset: end.offset });
45
+ index = text.indexOf(query, index + query.length);
46
+ }
47
+ }
48
+ return matches;
49
+ }
50
+ /** Makes the match the editor's selection, so it's highlighted and scrolled to. */
51
+ export function $selectMatch(match) {
52
+ const selection = $createRangeSelection();
53
+ selection.anchor.set(match.anchorKey, match.anchorOffset, 'text');
54
+ selection.focus.set(match.focusKey, match.focusOffset, 'text');
55
+ $setSelection(selection);
56
+ }
57
+ /**
58
+ * Replaces one match, keeping the formatting of the text around it. The
59
+ * replacement takes the formatting of the match's first character.
60
+ */
61
+ export function $replaceMatch(match, replacement) {
62
+ $selectMatch(match);
63
+ const selection = $getSelection();
64
+ if (!$isRangeSelection(selection))
65
+ return;
66
+ if (replacement === '')
67
+ selection.removeText();
68
+ else
69
+ selection.insertText(replacement);
70
+ }
71
+ /**
72
+ * Replaces every match. Walks from the last match to the first, so each
73
+ * replacement leaves the positions of the matches before it unchanged.
74
+ * Returns how many were replaced.
75
+ */
76
+ export function $replaceAll(query, replacement) {
77
+ const matches = $findMatches(query);
78
+ for (let i = matches.length - 1; i >= 0; i--)
79
+ $replaceMatch(matches[i], replacement);
80
+ return matches.length;
81
+ }