@inshapardaz/likhari-react 0.1.18-dev.148 → 0.1.18-dev.152

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.
@@ -7,14 +7,17 @@ export type SerializedPoetryBlockNode = Spread<{
7
7
  width?: number;
8
8
  }, SerializedElementNode>;
9
9
  /**
10
- * A couplet (two-line verse unit), the one extensible primitive requirements
11
- * doc §4.11 recommends in place of four fixed templates. `layout` picks
12
- * single-column (two stacked misras) vs. two-column (built on the columns
13
- * primitive, §4.9 — see blocks/poetryActions.ts for how children are
14
- * restructured between the two); `align` is a literal per-instance override,
15
- * independent of the document's own alignment setting (architecture doc
16
- * §3.1). Both mutate in place via poetryActions so converting a couplet
17
- * between layouts doesn't lose selection/undo coherence.
10
+ * A poetry *section*: one or more couplets (two-line verse units) sharing
11
+ * one layout — single-column stacks each couplet's two misras directly as
12
+ * paragraph children (two per couplet, in order), two-column gives each
13
+ * couplet its own LayoutContainerNode row (built on the columns primitive,
14
+ * §4.9) — see blocks/poetryActions.ts's $getCouplets for how either shape
15
+ * is read, and $setPoetryLayout/$exitPoetryOnEnter for how children are
16
+ * restructured/grown between couplets and layouts. `layout` and `align`
17
+ * apply to the whole section, not per couplet; both mutate in place via
18
+ * poetryActions so converting a section between layouts doesn't lose
19
+ * selection/undo coherence. One extensible primitive in place of four
20
+ * fixed templates, per requirements doc §4.11.
18
21
  */
19
22
  export declare class PoetryBlockNode extends ElementNode {
20
23
  __layout: PoetryLayout;
@@ -1 +1 @@
1
- {"version":3,"file":"PoetryNode.d.ts","sourceRoot":"","sources":["../../src/blocks/PoetryNode.tsx"],"names":[],"mappings":"AACA,OAAO,EAGL,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,KAAK,qBAAqB,EAC1B,KAAK,MAAM,EACX,WAAW,EACZ,MAAM,SAAS,CAAC;AAEjB,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,YAAY,CAAC;AACnD,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC;AAwBjE,MAAM,MAAM,yBAAyB,GAAG,MAAM,CAAC;IAAE,MAAM,EAAE,YAAY,CAAC;IAAC,KAAK,EAAE,WAAW,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,qBAAqB,CAAC,CAAC;AAEpI;;;;;;;;;GASG;AACH,qBAAa,eAAgB,SAAQ,WAAW;IAC9C,QAAQ,EAAE,YAAY,CAAC;IACvB,OAAO,EAAE,WAAW,CAAC;IACrB;8DAC0D;IAC1D,OAAO,CAAC,EAAE,MAAM,CAAC;gBAEL,MAAM,GAAE,YAAuB,EAAE,KAAK,GAAE,WAAuB,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO;IAO1G,MAAM,CAAC,OAAO,IAAI,MAAM;IAIxB,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,eAAe,GAAG,eAAe;IAIpD,SAAS,IAAI,YAAY;IAIzB,QAAQ,IAAI,WAAW;IAIvB,QAAQ,IAAI,MAAM,GAAG,SAAS;IAI9B,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAMzC;;gEAE4D;IAC5D,kBAAkB,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI;IAM9C,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI;IAMlC,MAAM,CAAC,UAAU,CAAC,cAAc,EAAE,yBAAyB,GAAG,eAAe;IAI7E,UAAU,IAAI,yBAAyB;IAYvC,MAAM,CAAC,SAAS,IAAI,gBAAgB,GAAG,IAAI;IAe3C,SAAS,IAAI,eAAe;IAW5B,SAAS,CAAC,MAAM,EAAE,YAAY,GAAG,WAAW;IAa5C,SAAS,CAAC,QAAQ,EAAE,eAAe,EAAE,GAAG,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO;IAerF,UAAU,IAAI,KAAK;IAInB,YAAY,IAAI,OAAO;CAGxB;AAED,wBAAgB,sBAAsB,CAAC,MAAM,GAAE,YAAuB,EAAE,KAAK,GAAE,WAAuB,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,eAAe,CAEvI;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,IAAI,eAAe,CAEhG"}
1
+ {"version":3,"file":"PoetryNode.d.ts","sourceRoot":"","sources":["../../src/blocks/PoetryNode.tsx"],"names":[],"mappings":"AACA,OAAO,EAGL,KAAK,gBAAgB,EACrB,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,KAAK,OAAO,EACZ,KAAK,qBAAqB,EAC1B,KAAK,MAAM,EACX,WAAW,EACZ,MAAM,SAAS,CAAC;AAEjB,MAAM,MAAM,YAAY,GAAG,QAAQ,GAAG,YAAY,CAAC;AACnD,MAAM,MAAM,WAAW,GAAG,SAAS,GAAG,MAAM,GAAG,OAAO,GAAG,OAAO,CAAC;AAwBjE,MAAM,MAAM,yBAAyB,GAAG,MAAM,CAAC;IAAE,MAAM,EAAE,YAAY,CAAC;IAAC,KAAK,EAAE,WAAW,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,EAAE,qBAAqB,CAAC,CAAC;AAEpI;;;;;;;;;;;;GAYG;AACH,qBAAa,eAAgB,SAAQ,WAAW;IAC9C,QAAQ,EAAE,YAAY,CAAC;IACvB,OAAO,EAAE,WAAW,CAAC;IACrB;8DAC0D;IAC1D,OAAO,CAAC,EAAE,MAAM,CAAC;gBAEL,MAAM,GAAE,YAAuB,EAAE,KAAK,GAAE,WAAuB,EAAE,KAAK,CAAC,EAAE,MAAM,EAAE,GAAG,CAAC,EAAE,OAAO;IAO1G,MAAM,CAAC,OAAO,IAAI,MAAM;IAIxB,MAAM,CAAC,KAAK,CAAC,IAAI,EAAE,eAAe,GAAG,eAAe;IAIpD,SAAS,IAAI,YAAY;IAIzB,QAAQ,IAAI,WAAW;IAIvB,QAAQ,IAAI,MAAM,GAAG,SAAS;IAI9B,QAAQ,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,IAAI;IAMzC;;gEAE4D;IAC5D,kBAAkB,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI;IAM9C,QAAQ,CAAC,KAAK,EAAE,WAAW,GAAG,IAAI;IAMlC,MAAM,CAAC,UAAU,CAAC,cAAc,EAAE,yBAAyB,GAAG,eAAe;IAI7E,UAAU,IAAI,yBAAyB;IAYvC,MAAM,CAAC,SAAS,IAAI,gBAAgB,GAAG,IAAI;IAe3C,SAAS,IAAI,eAAe;IAW5B,SAAS,CAAC,MAAM,EAAE,YAAY,GAAG,WAAW;IAa5C,SAAS,CAAC,QAAQ,EAAE,eAAe,EAAE,GAAG,EAAE,WAAW,EAAE,MAAM,EAAE,YAAY,GAAG,OAAO;IAerF,UAAU,IAAI,KAAK;IAInB,YAAY,IAAI,OAAO;CAGxB;AAED,wBAAgB,sBAAsB,CAAC,MAAM,GAAE,YAAuB,EAAE,KAAK,GAAE,WAAuB,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,eAAe,CAEvI;AAED,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,GAAG,IAAI,IAAI,eAAe,CAEhG"}
@@ -22,14 +22,17 @@ function applyWidthStyle(element, width) {
22
22
  element.style.maxWidth = `${width}px`;
23
23
  }
24
24
  /**
25
- * A couplet (two-line verse unit), the one extensible primitive requirements
26
- * doc §4.11 recommends in place of four fixed templates. `layout` picks
27
- * single-column (two stacked misras) vs. two-column (built on the columns
28
- * primitive, §4.9 — see blocks/poetryActions.ts for how children are
29
- * restructured between the two); `align` is a literal per-instance override,
30
- * independent of the document's own alignment setting (architecture doc
31
- * §3.1). Both mutate in place via poetryActions so converting a couplet
32
- * between layouts doesn't lose selection/undo coherence.
25
+ * A poetry *section*: one or more couplets (two-line verse units) sharing
26
+ * one layout — single-column stacks each couplet's two misras directly as
27
+ * paragraph children (two per couplet, in order), two-column gives each
28
+ * couplet its own LayoutContainerNode row (built on the columns primitive,
29
+ * §4.9) — see blocks/poetryActions.ts's $getCouplets for how either shape
30
+ * is read, and $setPoetryLayout/$exitPoetryOnEnter for how children are
31
+ * restructured/grown between couplets and layouts. `layout` and `align`
32
+ * apply to the whole section, not per couplet; both mutate in place via
33
+ * poetryActions so converting a section between layouts doesn't lose
34
+ * selection/undo coherence. One extensible primitive in place of four
35
+ * fixed templates, per requirements doc §4.11.
33
36
  */
34
37
  export class PoetryBlockNode extends ElementNode {
35
38
  constructor(layout = 'single', align = 'justify', width, key) {
@@ -1,51 +1,82 @@
1
1
  import { type ParagraphNode } from 'lexical';
2
2
  import { type PoetryAlign, type PoetryLayout, type PoetryBlockNode } from './PoetryNode.js';
3
- /** The couplet's two misra paragraphs, in document order, regardless of
4
- * which layout currently holds them. */
3
+ /** One couplet's two misra paragraphs, in order. */
4
+ export type Couplet = [ParagraphNode, ParagraphNode];
5
+ /**
6
+ * A poetry block is a *section* of one or more couplets sharing one layout
7
+ * (single-column stacks each couplet's two misras directly as paragraph
8
+ * children, two deep per couplet; two-column gives each couplet its own
9
+ * LayoutContainerNode row). This walks that structure and returns each
10
+ * couplet as a pair, in document order — the one place that knows how to
11
+ * read either shape, so nothing else needs to.
12
+ */
13
+ export declare function $getCouplets(node: PoetryBlockNode): Couplet[];
14
+ /** Every misra in the block, flattened across all its couplets — for
15
+ * operations (like $setPoetryLayout) that don't care about couplet
16
+ * boundaries, just the full set of lines. */
5
17
  export declare function $getMisraParagraphs(node: PoetryBlockNode): ParagraphNode[];
6
18
  /** The PoetryBlockNode the current selection is inside, or null. */
7
19
  export declare function $getPoetryBlockFromSelection(): PoetryBlockNode | null;
8
20
  /**
9
- * Converts a couplet between single-column (two stacked misras) and
10
- * two-column (built on the columns primitive, §4.9) in place — pulls the
11
- * two misra paragraphs out of whichever shape currently holds them and
12
- * rebuilds the other shape around the same paragraph nodes, so content,
13
- * selection and undo history all survive the conversion.
21
+ * Converts every couplet in the block between single-column (two stacked
22
+ * misras) and two-column (built on the columns primitive, §4.9) in place —
23
+ * pulls each couplet's two misra paragraphs out of whichever shape
24
+ * currently holds them and rebuilds the other shape around the same
25
+ * paragraph nodes, preserving couplet order, so content, selection and
26
+ * undo history all survive the conversion.
14
27
  */
15
28
  export declare function $setPoetryLayout(node: PoetryBlockNode, layout: PoetryLayout): void;
16
- /** A couplet at the very end of the document would otherwise leave no
29
+ /** A block at the very end of the document would otherwise leave no
17
30
  * editable block to click or arrow down into — appends one empty paragraph
18
- * if the couplet doesn't already have a next sibling. Idempotent: once a
31
+ * if the block doesn't already have a next sibling. Idempotent: once a
19
32
  * sibling exists (that paragraph, or anything else), this is a no-op, so
20
33
  * it's safe to call from both the insert command and a node transform that
21
34
  * keeps the invariant even after later edits (e.g. deleting the trailing
22
35
  * paragraph, or JSON content loaded without one). */
23
36
  export declare function $ensureTrailingParagraph(node: PoetryBlockNode): void;
24
- /** Inserts a new two-misra couplet after the selection's top-level block,
25
- * in `layout`/`align`, and focuses its first misra. */
37
+ /**
38
+ * Inserts a poetry couplet at the caret. If the caret is already inside a
39
+ * poetry block, appends a new couplet to *that* block (in its existing
40
+ * layout — "one poetry block can contain one or more couplets, in one or
41
+ * two column layout", so a block's layout is fixed once it has couplets,
42
+ * not chosen per couplet) and focuses its first misra. Otherwise creates a
43
+ * new block (in `layout`/`align`) with one couplet, after the selection's
44
+ * top-level element.
45
+ */
26
46
  export declare function $insertPoetryCouplet(layout: PoetryLayout, align: PoetryAlign): boolean;
27
- /** Removes the whole couplet the selection is inside, if any — the explicit,
28
- * always-available counterpart to $deletePoetryOnBackspace's empty-couplet
29
- * shortcut (mirrors the table menu's own "Delete table" action). */
47
+ /** Removes the couplet the selection is inside. If it's the block's only
48
+ * couplet, removes the whole block (an empty poetry section serves no
49
+ * purpose) — the explicit, always-available counterpart to
50
+ * $deletePoetryOnBackspace's empty-couplet shortcut (mirrors the table
51
+ * menu's own "Delete table" action). */
30
52
  export declare function $deletePoetryCouplet(): boolean;
31
53
  /**
32
- * Backspace at the very start of an empty couplet's first misra removes the
33
- * whole block — the natural keystroke to try first, and otherwise there was
34
- * no way to get rid of an empty couplet created by mistake (its two misra
35
- * paragraphs each have their own default canBeEmpty, so Lexical's usual
36
- * "delete the empty block" handling never reaches the couplet itself).
37
- * Returns false for every other caret position, including a non-empty
38
- * couplet, so normal Backspace handling (e.g. deleting within text) proceeds.
54
+ * Backspace at the very start of an empty couplet removes just that
55
+ * couplet — merging back into the previous one's end, or (if it's the
56
+ * block's only couplet) removing the whole block, since an empty poetry
57
+ * section serves no purpose. Otherwise there was no way to get rid of an
58
+ * empty couplet created by mistake (its misra paragraphs each have their
59
+ * own default canBeEmpty, so Lexical's usual "delete the empty block"
60
+ * handling never reaches the couplet itself). Returns false for every
61
+ * other caret position, including a non-empty couplet, so normal Backspace
62
+ * handling (e.g. deleting within text) proceeds.
39
63
  */
40
64
  export declare function $deletePoetryOnBackspace(): boolean;
41
65
  /**
42
- * Enter at the end of a couplet's last misra would otherwise land inside
43
- * ParagraphNode's own insertNewAfter — which inserts the new paragraph as a
44
- * sibling of the misra, i.e. still inside the couplet (or, for two-column,
45
- * inside its LayoutItemNode) — trapping the caret with no way to add a line
46
- * after the block. Handled here instead: a plain paragraph after the whole
47
- * couplet, focused. Returns false (let normal Enter handling proceed) for
48
- * every other caret position, including earlier in the couplet.
66
+ * Enter inside a poetry block never grows a couplet past its fixed two
67
+ * misras. The block's *last* couplet being completely empty is always the
68
+ * exit trigger, regardless of which of its two (both-empty) misras the
69
+ * caret happens to be on — pressing Enter once on a non-empty last couplet
70
+ * appends a fresh empty one to continue into ("new couplet after last"),
71
+ * and pressing Enter again on that still-untouched couplet exits ("double
72
+ * enter to exit the block"): the same empty-item convention most editors
73
+ * use for lists. Outside that case: pressed in a couplet's first misra,
74
+ * Enter moves the caret to the second (already there; nothing inserted —
75
+ * this is what stops ParagraphNode's default insertNewAfter from growing
76
+ * the couplet, which would otherwise insert the new paragraph as a sibling
77
+ * of wherever the caret was, i.e. still inside the couplet); pressed in
78
+ * the last misra of a couplet that isn't the block's last, it moves to the
79
+ * next couplet's first misra.
49
80
  */
50
81
  export declare function $exitPoetryOnEnter(): boolean;
51
82
  //# sourceMappingURL=poetryActions.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"poetryActions.d.ts","sourceRoot":"","sources":["../../src/blocks/poetryActions.ts"],"names":[],"mappings":"AACA,OAAO,EAA4F,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAEvI,OAAO,EAA8C,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAErI;wCACwC;AACxC,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,eAAe,GAAG,aAAa,EAAE,CAW1E;AAED,oEAAoE;AACpE,wBAAgB,4BAA4B,IAAI,eAAe,GAAG,IAAI,CAKrE;AAED;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,eAAe,EAAE,MAAM,EAAE,YAAY,GAAG,IAAI,CAclF;AAED;;;;;;qDAMqD;AACrD,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,eAAe,GAAG,IAAI,CAEpE;AAED;uDACuD;AACvD,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAyBtF;AAED;;oEAEoE;AACpE,wBAAgB,oBAAoB,IAAI,OAAO,CAK9C;AAED;;;;;;;;GAQG;AACH,wBAAgB,wBAAwB,IAAI,OAAO,CAoBlD;AAED;;;;;;;;GAQG;AACH,wBAAgB,kBAAkB,IAAI,OAAO,CAuB5C"}
1
+ {"version":3,"file":"poetryActions.d.ts","sourceRoot":"","sources":["../../src/blocks/poetryActions.ts"],"names":[],"mappings":"AACA,OAAO,EAA4F,KAAK,aAAa,EAAE,MAAM,SAAS,CAAC;AAEvI,OAAO,EAA8C,KAAK,WAAW,EAAE,KAAK,YAAY,EAAE,KAAK,eAAe,EAAE,MAAM,cAAc,CAAC;AAErI,oDAAoD;AACpD,MAAM,MAAM,OAAO,GAAG,CAAC,aAAa,EAAE,aAAa,CAAC,CAAC;AAErD;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,eAAe,GAAG,OAAO,EAAE,CAgB7D;AAED;;6CAE6C;AAC7C,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,eAAe,GAAG,aAAa,EAAE,CAE1E;AAED,oEAAoE;AACpE,wBAAgB,4BAA4B,IAAI,eAAe,GAAG,IAAI,CAKrE;AA2BD;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,eAAe,EAAE,MAAM,EAAE,YAAY,GAAG,IAAI,CAmBlF;AAED;;;;;;qDAMqD;AACrD,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,eAAe,GAAG,IAAI,CAEpE;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,EAAE,YAAY,EAAE,KAAK,EAAE,WAAW,GAAG,OAAO,CAmBtF;AAED;;;;wCAIwC;AACxC,wBAAgB,oBAAoB,IAAI,OAAO,CAwB9C;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,wBAAwB,IAAI,OAAO,CAqClD;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,kBAAkB,IAAI,OAAO,CAgD5C"}
@@ -2,20 +2,38 @@ import { $findMatchingParent } from '@lexical/utils';
2
2
  import { $createParagraphNode, $getSelection, $isElementNode, $isParagraphNode, $isRangeSelection } from 'lexical';
3
3
  import { $createLayoutContainerNode, $createLayoutItemNode, $isLayoutContainerNode, $isLayoutItemNode } from './LayoutNode.js';
4
4
  import { $createPoetryBlockNode, $isPoetryBlockNode } from './PoetryNode.js';
5
- /** The couplet's two misra paragraphs, in document order, regardless of
6
- * which layout currently holds them. */
7
- export function $getMisraParagraphs(node) {
5
+ /**
6
+ * A poetry block is a *section* of one or more couplets sharing one layout
7
+ * (single-column stacks each couplet's two misras directly as paragraph
8
+ * children, two deep per couplet; two-column gives each couplet its own
9
+ * LayoutContainerNode row). This walks that structure and returns each
10
+ * couplet as a pair, in document order — the one place that knows how to
11
+ * read either shape, so nothing else needs to.
12
+ */
13
+ export function $getCouplets(node) {
8
14
  if (node.getLayout() === 'single') {
9
- return node.getChildren().filter($isParagraphNode);
10
- }
11
- const container = node.getChildren().find($isLayoutContainerNode);
12
- if (!container)
13
- return [];
14
- return container
15
- .getChildren()
16
- .filter($isLayoutItemNode)
17
- .map((item) => item.getFirstChild())
18
- .filter($isParagraphNode);
15
+ const paragraphs = node.getChildren().filter($isParagraphNode);
16
+ const couplets = [];
17
+ for (let i = 0; i + 1 < paragraphs.length; i += 2)
18
+ couplets.push([paragraphs[i], paragraphs[i + 1]]);
19
+ return couplets;
20
+ }
21
+ const containers = node.getChildren().filter($isLayoutContainerNode);
22
+ const couplets = [];
23
+ for (const container of containers) {
24
+ const items = container.getChildren().filter($isLayoutItemNode);
25
+ const a = items[0]?.getFirstChild();
26
+ const b = items[1]?.getFirstChild();
27
+ if ($isParagraphNode(a) && $isParagraphNode(b))
28
+ couplets.push([a, b]);
29
+ }
30
+ return couplets;
31
+ }
32
+ /** Every misra in the block, flattened across all its couplets — for
33
+ * operations (like $setPoetryLayout) that don't care about couplet
34
+ * boundaries, just the full set of lines. */
35
+ export function $getMisraParagraphs(node) {
36
+ return $getCouplets(node).flat();
19
37
  }
20
38
  /** The PoetryBlockNode the current selection is inside, or null. */
21
39
  export function $getPoetryBlockFromSelection() {
@@ -25,36 +43,65 @@ export function $getPoetryBlockFromSelection() {
25
43
  const match = $findMatchingParent(selection.anchor.getNode(), $isPoetryBlockNode);
26
44
  return match ?? null;
27
45
  }
46
+ /** Which couplet (and which of its two misras, 0 or 1) a node sits inside,
47
+ * or null if it's in neither — e.g. on the block's own boundary. */
48
+ function $findCoupletPosition(couplets, target) {
49
+ for (let c = 0; c < couplets.length; c++) {
50
+ for (const misraIndex of [0, 1]) {
51
+ const misra = couplets[c][misraIndex];
52
+ if (misra.getKey() === target.getKey() || misra.isParentOf(target))
53
+ return { coupletIndex: c, misraIndex };
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+ function $appendCoupletTo(node) {
59
+ const misraA = $createParagraphNode();
60
+ const misraB = $createParagraphNode();
61
+ if (node.getLayout() === 'single') {
62
+ node.append(misraA, misraB);
63
+ }
64
+ else {
65
+ const container = $createLayoutContainerNode('repeat(2, 1fr)');
66
+ container.append($createLayoutItemNode().append(misraA), $createLayoutItemNode().append(misraB));
67
+ node.append(container);
68
+ }
69
+ return [misraA, misraB];
70
+ }
28
71
  /**
29
- * Converts a couplet between single-column (two stacked misras) and
30
- * two-column (built on the columns primitive, §4.9) in place — pulls the
31
- * two misra paragraphs out of whichever shape currently holds them and
32
- * rebuilds the other shape around the same paragraph nodes, so content,
33
- * selection and undo history all survive the conversion.
72
+ * Converts every couplet in the block between single-column (two stacked
73
+ * misras) and two-column (built on the columns primitive, §4.9) in place —
74
+ * pulls each couplet's two misra paragraphs out of whichever shape
75
+ * currently holds them and rebuilds the other shape around the same
76
+ * paragraph nodes, preserving couplet order, so content, selection and
77
+ * undo history all survive the conversion.
34
78
  */
35
79
  export function $setPoetryLayout(node, layout) {
36
80
  if (node.getLayout() === layout)
37
81
  return;
38
- const misras = $getMisraParagraphs(node);
39
- for (const misra of misras)
40
- misra.remove();
82
+ const couplets = $getCouplets(node);
83
+ for (const [a, b] of couplets) {
84
+ a.remove();
85
+ b.remove();
86
+ }
41
87
  for (const leftover of node.getChildren())
42
88
  leftover.remove();
43
89
  if (layout === 'single') {
44
- for (const misra of misras)
45
- node.append(misra);
90
+ for (const [a, b] of couplets)
91
+ node.append(a, b);
46
92
  }
47
93
  else {
48
- const container = $createLayoutContainerNode('repeat(2, 1fr)');
49
- for (const misra of misras)
50
- container.append($createLayoutItemNode().append(misra));
51
- node.append(container);
94
+ for (const [a, b] of couplets) {
95
+ const container = $createLayoutContainerNode('repeat(2, 1fr)');
96
+ container.append($createLayoutItemNode().append(a), $createLayoutItemNode().append(b));
97
+ node.append(container);
98
+ }
52
99
  }
53
100
  node.setLayoutAttribute(layout);
54
101
  }
55
- /** A couplet at the very end of the document would otherwise leave no
102
+ /** A block at the very end of the document would otherwise leave no
56
103
  * editable block to click or arrow down into — appends one empty paragraph
57
- * if the couplet doesn't already have a next sibling. Idempotent: once a
104
+ * if the block doesn't already have a next sibling. Idempotent: once a
58
105
  * sibling exists (that paragraph, or anything else), this is a no-op, so
59
106
  * it's safe to call from both the insert command and a node transform that
60
107
  * keeps the invariant even after later edits (e.g. deleting the trailing
@@ -63,107 +110,173 @@ export function $ensureTrailingParagraph(node) {
63
110
  if (node.getNextSibling() === null)
64
111
  node.insertAfter($createParagraphNode());
65
112
  }
66
- /** Inserts a new two-misra couplet after the selection's top-level block,
67
- * in `layout`/`align`, and focuses its first misra. */
113
+ /**
114
+ * Inserts a poetry couplet at the caret. If the caret is already inside a
115
+ * poetry block, appends a new couplet to *that* block (in its existing
116
+ * layout — "one poetry block can contain one or more couplets, in one or
117
+ * two column layout", so a block's layout is fixed once it has couplets,
118
+ * not chosen per couplet) and focuses its first misra. Otherwise creates a
119
+ * new block (in `layout`/`align`) with one couplet, after the selection's
120
+ * top-level element.
121
+ */
68
122
  export function $insertPoetryCouplet(layout, align) {
69
123
  const selection = $getSelection();
70
124
  if (!$isRangeSelection(selection))
71
125
  return false;
72
- // getTopLevelElement() stops at the nearest shadow root, and a
73
- // PoetryBlockNode is one — so from inside an existing couplet it resolves
74
- // to the misra paragraph itself, not the couplet. Without this check, a
75
- // second insert lands as a sibling of that misra (nested inside the first
76
- // couplet) instead of after the couplet as a whole.
77
- const anchorTopLevel = $getPoetryBlockFromSelection() ?? selection.anchor.getNode().getTopLevelElementOrThrow();
78
- const node = $createPoetryBlockNode(layout, align);
79
- const misraA = $createParagraphNode();
80
- const misraB = $createParagraphNode();
81
- if (layout === 'single') {
82
- node.append(misraA, misraB);
83
- }
84
- else {
85
- const container = $createLayoutContainerNode('repeat(2, 1fr)');
86
- container.append($createLayoutItemNode().append(misraA), $createLayoutItemNode().append(misraB));
87
- node.append(container);
126
+ const existing = $getPoetryBlockFromSelection();
127
+ if (existing) {
128
+ const [misraA] = $appendCoupletTo(existing);
129
+ misraA.selectStart();
130
+ return true;
88
131
  }
132
+ const anchorTopLevel = selection.anchor.getNode().getTopLevelElementOrThrow();
133
+ const node = $createPoetryBlockNode(layout, align);
134
+ const [misraA] = $appendCoupletTo(node);
89
135
  anchorTopLevel.insertAfter(node);
90
136
  $ensureTrailingParagraph(node);
91
137
  misraA.selectStart();
92
138
  return true;
93
139
  }
94
- /** Removes the whole couplet the selection is inside, if any — the explicit,
95
- * always-available counterpart to $deletePoetryOnBackspace's empty-couplet
96
- * shortcut (mirrors the table menu's own "Delete table" action). */
140
+ /** Removes the couplet the selection is inside. If it's the block's only
141
+ * couplet, removes the whole block (an empty poetry section serves no
142
+ * purpose) — the explicit, always-available counterpart to
143
+ * $deletePoetryOnBackspace's empty-couplet shortcut (mirrors the table
144
+ * menu's own "Delete table" action). */
97
145
  export function $deletePoetryCouplet() {
98
- const couplet = $getPoetryBlockFromSelection();
99
- if (!couplet)
146
+ const block = $getPoetryBlockFromSelection();
147
+ if (!block)
100
148
  return false;
101
- couplet.remove();
149
+ const selection = $getSelection();
150
+ const couplets = $getCouplets(block);
151
+ const anchorNode = $isRangeSelection(selection) ? selection.anchor.getNode() : null;
152
+ const position = anchorNode ? $findCoupletPosition(couplets, anchorNode) : null;
153
+ const target = position ? couplets[position.coupletIndex] : couplets[couplets.length - 1];
154
+ if (!target)
155
+ return false;
156
+ if (couplets.length <= 1) {
157
+ block.remove();
158
+ return true;
159
+ }
160
+ const [a, b] = target;
161
+ const container = $findMatchingParent(a, $isLayoutContainerNode);
162
+ if (container)
163
+ container.remove();
164
+ else {
165
+ a.remove();
166
+ b.remove();
167
+ }
102
168
  return true;
103
169
  }
104
170
  /**
105
- * Backspace at the very start of an empty couplet's first misra removes the
106
- * whole block — the natural keystroke to try first, and otherwise there was
107
- * no way to get rid of an empty couplet created by mistake (its two misra
108
- * paragraphs each have their own default canBeEmpty, so Lexical's usual
109
- * "delete the empty block" handling never reaches the couplet itself).
110
- * Returns false for every other caret position, including a non-empty
111
- * couplet, so normal Backspace handling (e.g. deleting within text) proceeds.
171
+ * Backspace at the very start of an empty couplet removes just that
172
+ * couplet — merging back into the previous one's end, or (if it's the
173
+ * block's only couplet) removing the whole block, since an empty poetry
174
+ * section serves no purpose. Otherwise there was no way to get rid of an
175
+ * empty couplet created by mistake (its misra paragraphs each have their
176
+ * own default canBeEmpty, so Lexical's usual "delete the empty block"
177
+ * handling never reaches the couplet itself). Returns false for every
178
+ * other caret position, including a non-empty couplet, so normal Backspace
179
+ * handling (e.g. deleting within text) proceeds.
112
180
  */
113
181
  export function $deletePoetryOnBackspace() {
114
182
  const selection = $getSelection();
115
183
  if (!$isRangeSelection(selection) || !selection.isCollapsed())
116
184
  return false;
117
- const couplet = $getPoetryBlockFromSelection();
118
- if (!couplet)
119
- return false;
120
- const misras = $getMisraParagraphs(couplet);
121
- const firstMisra = misras[0];
122
- if (!firstMisra)
185
+ const block = $getPoetryBlockFromSelection();
186
+ if (!block)
123
187
  return false;
124
- if (misras.some((m) => !m.isEmpty()))
188
+ const couplets = $getCouplets(block);
189
+ const anchorNode = selection.anchor.getNode();
190
+ const position = $findCoupletPosition(couplets, anchorNode);
191
+ if (!position || position.misraIndex !== 0)
125
192
  return false;
193
+ const [firstMisra] = couplets[position.coupletIndex];
126
194
  const anchor = selection.anchor;
127
195
  const isAtStart = anchor.key === firstMisra.getKey() && anchor.offset === 0;
128
196
  if (!isAtStart)
129
197
  return false;
130
- const previous = couplet.getPreviousSibling();
131
- couplet.remove();
132
- if ($isElementNode(previous))
133
- previous.selectEnd();
198
+ const [a, b] = couplets[position.coupletIndex];
199
+ if (!a.isEmpty() || !b.isEmpty())
200
+ return false;
201
+ if (couplets.length <= 1) {
202
+ const previous = block.getPreviousSibling();
203
+ block.remove();
204
+ if ($isElementNode(previous))
205
+ previous.selectEnd();
206
+ return true;
207
+ }
208
+ const container = $findMatchingParent(a, $isLayoutContainerNode);
209
+ if (container)
210
+ container.remove();
211
+ else {
212
+ a.remove();
213
+ b.remove();
214
+ }
215
+ const remaining = $getCouplets(block);
216
+ const previousCouplet = remaining[position.coupletIndex - 1] ?? remaining[0];
217
+ previousCouplet?.[1].selectEnd();
134
218
  return true;
135
219
  }
136
220
  /**
137
- * Enter at the end of a couplet's last misra would otherwise land inside
138
- * ParagraphNode's own insertNewAfter — which inserts the new paragraph as a
139
- * sibling of the misra, i.e. still inside the couplet (or, for two-column,
140
- * inside its LayoutItemNode) — trapping the caret with no way to add a line
141
- * after the block. Handled here instead: a plain paragraph after the whole
142
- * couplet, focused. Returns false (let normal Enter handling proceed) for
143
- * every other caret position, including earlier in the couplet.
221
+ * Enter inside a poetry block never grows a couplet past its fixed two
222
+ * misras. The block's *last* couplet being completely empty is always the
223
+ * exit trigger, regardless of which of its two (both-empty) misras the
224
+ * caret happens to be on — pressing Enter once on a non-empty last couplet
225
+ * appends a fresh empty one to continue into ("new couplet after last"),
226
+ * and pressing Enter again on that still-untouched couplet exits ("double
227
+ * enter to exit the block"): the same empty-item convention most editors
228
+ * use for lists. Outside that case: pressed in a couplet's first misra,
229
+ * Enter moves the caret to the second (already there; nothing inserted —
230
+ * this is what stops ParagraphNode's default insertNewAfter from growing
231
+ * the couplet, which would otherwise insert the new paragraph as a sibling
232
+ * of wherever the caret was, i.e. still inside the couplet); pressed in
233
+ * the last misra of a couplet that isn't the block's last, it moves to the
234
+ * next couplet's first misra.
144
235
  */
145
236
  export function $exitPoetryOnEnter() {
146
237
  const selection = $getSelection();
147
238
  if (!$isRangeSelection(selection) || !selection.isCollapsed())
148
239
  return false;
149
- const couplet = $getPoetryBlockFromSelection();
150
- if (!couplet)
151
- return false;
152
- const misras = $getMisraParagraphs(couplet);
153
- const lastMisra = misras[misras.length - 1];
154
- if (!lastMisra)
240
+ const block = $getPoetryBlockFromSelection();
241
+ if (!block)
155
242
  return false;
156
- const anchor = selection.anchor;
157
- const lastDesc = lastMisra.getLastDescendant();
158
- const isAtEnd = lastDesc ? anchor.key === lastDesc.getKey() && anchor.offset === lastDesc.getTextContentSize() : anchor.key === lastMisra.getKey();
159
- if (!isAtEnd)
243
+ const couplets = $getCouplets(block);
244
+ const position = $findCoupletPosition(couplets, selection.anchor.getNode());
245
+ if (!position)
160
246
  return false;
161
- // $ensureTrailingParagraph already guarantees one after every couplet —
162
- // reuse it rather than stacking a new empty paragraph on repeated Enters.
163
- const existing = couplet.getNextSibling();
164
- const paragraph = $isParagraphNode(existing) ? existing : $createParagraphNode();
165
- if (paragraph !== existing)
166
- couplet.insertAfter(paragraph);
167
- paragraph.select();
247
+ const { coupletIndex, misraIndex } = position;
248
+ const [a, b] = couplets[coupletIndex];
249
+ const isLastCouplet = coupletIndex === couplets.length - 1;
250
+ const coupletEmpty = a.isEmpty() && b.isEmpty();
251
+ if (isLastCouplet && coupletEmpty) {
252
+ // Drop the couplet we ourselves auto-created on the previous Enter —
253
+ // unless it's the block's only couplet, in which case leave the
254
+ // section as the user's (empty) content rather than deleting it.
255
+ if (couplets.length > 1) {
256
+ const container = $findMatchingParent(a, $isLayoutContainerNode);
257
+ if (container)
258
+ container.remove();
259
+ else {
260
+ a.remove();
261
+ b.remove();
262
+ }
263
+ }
264
+ const existingNext = block.getNextSibling();
265
+ const paragraph = $isParagraphNode(existingNext) ? existingNext : $createParagraphNode();
266
+ if (paragraph !== existingNext)
267
+ block.insertAfter(paragraph);
268
+ paragraph.select();
269
+ return true;
270
+ }
271
+ if (misraIndex === 0) {
272
+ b.selectEnd();
273
+ return true;
274
+ }
275
+ if (!isLastCouplet) {
276
+ couplets[coupletIndex + 1][0].selectEnd();
277
+ return true;
278
+ }
279
+ const [newA] = $appendCoupletTo(block);
280
+ newA.selectStart();
168
281
  return true;
169
282
  }
@@ -406,13 +406,18 @@
406
406
 
407
407
  /* Misra lines sit tight against each other within a couplet — the generic
408
408
  .likhari-paragraph margin would otherwise add the same gap between a
409
- couplet's own two lines as between separate couplets, losing the pairing
410
- entirely. The couplet's own margin-bottom (above) is the only gap that
411
- should read as "extra space", and it falls after every second line. */
409
+ couplet's own two lines as between separate couplets within the same
410
+ block, losing the pairing entirely. Re-added below on every *second*
411
+ line (nth-child(2n)) — one couplet's worth of lines — so the extra space
412
+ falls after every couplet, not after every single line. */
412
413
  .likhari-poetry .likhari-paragraph {
413
414
  margin: 0;
414
415
  }
415
416
 
417
+ .likhari-poetry--single .likhari-paragraph:nth-child(2n):not(:last-child) {
418
+ margin-bottom: 1em;
419
+ }
420
+
416
421
  .likhari-poetry--two-column .likhari-layout-container {
417
422
  margin: 0;
418
423
  /* No grid gap — the divider below sits exactly on the track boundary, so
@@ -421,6 +426,12 @@
421
426
  gap: 0;
422
427
  }
423
428
 
429
+ /* One row per couplet in two-column layout — same "extra space after every
430
+ couplet" rhythm as the single-column rule above. */
431
+ .likhari-poetry--two-column .likhari-layout-container:not(:last-child) {
432
+ margin-bottom: 1em;
433
+ }
434
+
424
435
  .likhari-poetry--two-column .likhari-layout-item {
425
436
  text-align: inherit;
426
437
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inshapardaz/likhari-react",
3
- "version": "0.1.18-dev.148",
3
+ "version": "0.1.18-dev.152",
4
4
  "description": "React implementation of the Likhari editor, built on @lexical/react.",
5
5
  "license": "MIT",
6
6
  "repository": {