@kywi-software/core 0.10.0 → 0.11.0

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 (53) hide show
  1. package/AGENT-PATTERNS.md +15 -13
  2. package/README.md +67 -0
  3. package/dist/admin/layout-editor/editor-canvas.d.ts +20 -7
  4. package/dist/admin/layout-editor/editor-canvas.d.ts.map +1 -1
  5. package/dist/admin/layout-editor/editor-canvas.js +31 -26
  6. package/dist/admin/layout-editor/editor-canvas.js.map +1 -1
  7. package/dist/admin/layout-editor/editor.css +87 -2
  8. package/dist/admin/layout-editor/inline-text.d.ts +56 -4
  9. package/dist/admin/layout-editor/inline-text.d.ts.map +1 -1
  10. package/dist/admin/layout-editor/inline-text.js +134 -11
  11. package/dist/admin/layout-editor/inline-text.js.map +1 -1
  12. package/dist/admin/layout-editor/module-overlay.d.ts +26 -13
  13. package/dist/admin/layout-editor/module-overlay.d.ts.map +1 -1
  14. package/dist/admin/layout-editor/module-overlay.js +200 -21
  15. package/dist/admin/layout-editor/module-overlay.js.map +1 -1
  16. package/dist/admin/layout-editor/overlay-shell.d.ts.map +1 -1
  17. package/dist/admin/layout-editor/overlay-shell.js +38 -11
  18. package/dist/admin/layout-editor/overlay-shell.js.map +1 -1
  19. package/dist/admin/layout-editor/props-panel.js +1 -1
  20. package/dist/admin/layout-editor/props-panel.js.map +1 -1
  21. package/dist/admin/layout-editor/section-overlay.d.ts +8 -1
  22. package/dist/admin/layout-editor/section-overlay.d.ts.map +1 -1
  23. package/dist/admin/layout-editor/section-overlay.js +8 -8
  24. package/dist/admin/layout-editor/section-overlay.js.map +1 -1
  25. package/dist/admin/layout-editor/section-variant-container-config.js +5 -5
  26. package/dist/admin/layout-editor/section-variant-container-config.js.map +1 -1
  27. package/dist/admin/layout-editor/use-chrome-flip.d.ts +111 -11
  28. package/dist/admin/layout-editor/use-chrome-flip.d.ts.map +1 -1
  29. package/dist/admin/layout-editor/use-chrome-flip.js +233 -33
  30. package/dist/admin/layout-editor/use-chrome-flip.js.map +1 -1
  31. package/dist/admin/styles/admin.css +14 -1
  32. package/dist/layout/comments-module.d.ts.map +1 -1
  33. package/dist/layout/comments-module.js +6 -2
  34. package/dist/layout/comments-module.js.map +1 -1
  35. package/dist/layout/module-render-context.d.ts +32 -0
  36. package/dist/layout/module-render-context.d.ts.map +1 -1
  37. package/dist/layout/module-render-context.js +32 -1
  38. package/dist/layout/module-render-context.js.map +1 -1
  39. package/dist/layout/modules-extended.d.ts.map +1 -1
  40. package/dist/layout/modules-extended.js +21 -11
  41. package/dist/layout/modules-extended.js.map +1 -1
  42. package/dist/layout/modules.d.ts.map +1 -1
  43. package/dist/layout/modules.js +15 -10
  44. package/dist/layout/modules.js.map +1 -1
  45. package/dist/scope/inline-editing.d.ts.map +1 -1
  46. package/dist/scope/inline-editing.js +10 -0
  47. package/dist/scope/inline-editing.js.map +1 -1
  48. package/dist/scope/sticky-offset.d.ts +185 -0
  49. package/dist/scope/sticky-offset.d.ts.map +1 -0
  50. package/dist/scope/sticky-offset.js +432 -0
  51. package/dist/scope/sticky-offset.js.map +1 -0
  52. package/dist/site/styles.css +41 -3
  53. package/package.json +1 -1
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * inline-text.ts
3
- * Which modules can be text-edited directly on the page, and how to read the
4
- * edited value back out of the DOM (kywi-cms#94).
3
+ * Which modules can be text-edited directly on the page, WHICH ELEMENT each
4
+ * edit binds to, and how to read the edited value back out of the DOM
5
+ * (kywi-cms#94, kywi-cms#145).
5
6
  *
6
7
  * The front-of-site editor makes a text-bearing module's own rendered element
7
8
  * `contenteditable` — the real `<h2>`, `<p>` or rich-text `<div>` the visitor
@@ -17,6 +18,21 @@ export interface InlineTextProp {
17
18
  /** True when the prop holds HTML (rich text) rather than a plain string. */
18
19
  html: boolean;
19
20
  }
21
+ /**
22
+ * How an edit reached its element.
23
+ *
24
+ * `anchor` — the renderer marked the element with `data-kywi-prop`, so it holds
25
+ * that prop's copy and nothing else (see `useInlinePropAnchor`).
26
+ * `wrapper` — the legacy whole-module binding, allowed ONLY for a module with a
27
+ * single text prop (see `resolveInlineBinding` rule 3).
28
+ */
29
+ export type InlineBindingKind = 'anchor' | 'wrapper';
30
+ /** The element + prop one inline edit is bound to. */
31
+ export interface InlineTextBindingTarget {
32
+ prop: InlineTextProp;
33
+ element: HTMLElement;
34
+ kind: InlineBindingKind;
35
+ }
20
36
  /**
21
37
  * The module's *primary* text prop, or null when it has none.
22
38
  *
@@ -27,11 +43,47 @@ export interface InlineTextProp {
27
43
  * out of inline editing, where typing over the picture would be nonsense.
28
44
  */
29
45
  export declare function resolveInlineTextProp(config: ModuleConfig | null | undefined): InlineTextProp | null;
46
+ /**
47
+ * EVERY text-typed prop a module declares, in declaration order.
48
+ *
49
+ * This is the config-side half of the #145 safety guard: a module with more than
50
+ * one of these can never be bound at the wrapper, because the wrapper renders
51
+ * all of them and a commit would concatenate them into whichever one was bound.
52
+ */
53
+ export declare function listInlineTextProps(config: ModuleConfig | null | undefined): InlineTextProp[];
54
+ /**
55
+ * Which element + prop a double-click inside `wrapper` binds — or null when
56
+ * there is nothing safe to bind, in which case the caller routes the gesture to
57
+ * the props-rail fallback (kywi-cms#118) rather than to the wrapper.
58
+ *
59
+ * The rules, in order (kywi-cms#145):
60
+ *
61
+ * 1. The anchor the gesture actually landed in. Double-clicking a card's body
62
+ * edits the body, its footer edits the footer — the prop the owner pointed
63
+ * at, not "the module's first prop".
64
+ * 2. Otherwise, an unambiguous single anchor in the module: a gesture anywhere
65
+ * on a module with only one editable element can only have meant that one.
66
+ * Two or more anchors and a gesture that landed on neither is genuinely
67
+ * ambiguous → fallback.
68
+ * 3. Otherwise (a custom module with no anchors at all): the legacy whole-
69
+ * wrapper binding, and ONLY when the module declares exactly one text prop —
70
+ * the case where "everything the wrapper renders" and "the bound prop" cannot
71
+ * disagree. This is what keeps heading/text/richText/button working for
72
+ * module packs that never adopt the anchors.
73
+ *
74
+ * Anything else fails closed. A wrapper binding on a multi-text-prop module is
75
+ * the #145 corruption itself, so it is not reachable from here by any path.
76
+ */
77
+ export declare function resolveInlineBinding(config: ModuleConfig | null | undefined, wrapper: HTMLElement, target: Element | null): InlineTextBindingTarget | null;
30
78
  /**
31
79
  * Read the edited value back out of an editing host.
32
80
  *
33
- * The host is the module's wrapper element, which also carries the editor's own
34
- * floating chrome (type chip, action cluster). Chrome is marked with
81
+ * For an anchored host (`data-kywi-prop`) the host IS the prop's element — it
82
+ * renders that prop's copy and nothing else, editor chrome included so its
83
+ * content is the value, directly.
84
+ *
85
+ * For the legacy wrapper host the host also carries the editor's own floating
86
+ * chrome (type chip, action cluster). Chrome is marked with
35
87
  * `data-kywi-editor-chrome` and skipped, so the value comes from the module's
36
88
  * own rendered element — `innerHTML` for a rich-text prop, `textContent` for a
37
89
  * plain one.
@@ -1 +1 @@
1
- {"version":3,"file":"inline-text.d.ts","sourceRoot":"","sources":["../../../src/admin/layout-editor/inline-text.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAwB,MAAM,uBAAuB,CAAA;AAG/E,mDAAmD;AACnD,MAAM,WAAW,cAAc;IAC7B,sFAAsF;IACtF,IAAI,EAAE,MAAM,CAAA;IACZ,4EAA4E;IAC5E,IAAI,EAAE,OAAO,CAAA;CACd;AAaD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,GAAG,SAAS,GAAG,cAAc,GAAG,IAAI,CASpG;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,CAS5E"}
1
+ {"version":3,"file":"inline-text.d.ts","sourceRoot":"","sources":["../../../src/admin/layout-editor/inline-text.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAwB,MAAM,uBAAuB,CAAA;AAI/E,mDAAmD;AACnD,MAAM,WAAW,cAAc;IAC7B,sFAAsF;IACtF,IAAI,EAAE,MAAM,CAAA;IACZ,4EAA4E;IAC5E,IAAI,EAAE,OAAO,CAAA;CACd;AAED;;;;;;;GAOG;AACH,MAAM,MAAM,iBAAiB,GAAG,QAAQ,GAAG,SAAS,CAAA;AAEpD,sDAAsD;AACtD,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,cAAc,CAAA;IACpB,OAAO,EAAE,WAAW,CAAA;IACpB,IAAI,EAAE,iBAAiB,CAAA;CACxB;AAuCD;;;;;;;;GAQG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,GAAG,SAAS,GAAG,cAAc,GAAG,IAAI,CASpG;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,YAAY,GAAG,IAAI,GAAG,SAAS,GAAG,cAAc,EAAE,CAO7F;AAiBD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,oBAAoB,CAClC,MAAM,EAAE,YAAY,GAAG,IAAI,GAAG,SAAS,EACvC,OAAO,EAAE,WAAW,EACpB,MAAM,EAAE,OAAO,GAAG,IAAI,GACrB,uBAAuB,GAAG,IAAI,CAoChC;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,OAAO,GAAG,MAAM,CAU5E"}
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * inline-text.ts
3
- * Which modules can be text-edited directly on the page, and how to read the
4
- * edited value back out of the DOM (kywi-cms#94).
3
+ * Which modules can be text-edited directly on the page, WHICH ELEMENT each
4
+ * edit binds to, and how to read the edited value back out of the DOM
5
+ * (kywi-cms#94, kywi-cms#145).
5
6
  *
6
7
  * The front-of-site editor makes a text-bearing module's own rendered element
7
8
  * `contenteditable` — the real `<h2>`, `<p>` or rich-text `<div>` the visitor
@@ -9,16 +10,43 @@
9
10
  * Committing writes the SAME prop the props panel writes, through the SAME
10
11
  * `UPDATE_PROPS` action, so there is one save path and undo/redo just work.
11
12
  */
13
+ import { INLINE_PROP_ATTR } from '../../layout/module-render-context.js';
12
14
  import { sanitizeHtml } from '../../util/sanitize.js';
13
15
  /** Prop types that hold editable copy. */
14
16
  const TEXT_PROP_TYPES = new Set(['text', 'textarea', 'richText']);
15
17
  /**
16
- * Modules whose first prop happens to be text-typed but must never be edited by
17
- * typing on the page: `html`'s `code` prop is a raw-HTML escape hatch (admin
18
- * only, see ADMIN_ONLY_MODULE_TYPES) where contenteditable would mangle the
19
- * source the author is deliberately hand-writing.
18
+ * Modules whose text-typed props must never be edited by typing on the page,
19
+ * because what the module RENDERS is not what the prop HOLDS.
20
+ *
21
+ * `html`/`embed` hold raw source an author is deliberately hand-writing;
22
+ * `tabs`/`carousel`/`tagCloud`/`breadcrumbs` hold JSON; `spacer` holds a CSS
23
+ * length; `feedDisplay`/`categoryList` hold a slug/id. Each of those is a
24
+ * single-text-prop module, so without this list rule 3 below would keep
25
+ * offering them the legacy wrapper binding — and a commit would serialize the
26
+ * module's rendered text over the JSON/length/slug the prop actually holds
27
+ * (kywi-cms#145: the same silent corruption, one class out). Double-click on
28
+ * these now routes to the props-rail fallback (kywi-cms#118) instead.
29
+ *
30
+ * `container` and `columns` are listed for INTENT, not because they would
31
+ * otherwise slip through: `container` declares two text props (`maxWidth`,
32
+ * `padding`) so rule 3 already rejects it, and `columns` leads with a `select`
33
+ * so prop resolution already rejects it. They are here so that a later prop
34
+ * edit to either — dropping one of the container's two, reordering the columns
35
+ * — cannot quietly re-open a hole.
20
36
  */
21
- const NOT_INLINE_EDITABLE = new Set(['html']);
37
+ const NOT_INLINE_EDITABLE = new Set([
38
+ 'html',
39
+ 'embed',
40
+ 'tabs',
41
+ 'carousel',
42
+ 'tagCloud',
43
+ 'breadcrumbs',
44
+ 'container',
45
+ 'columns',
46
+ 'spacer',
47
+ 'feedDisplay',
48
+ 'categoryList',
49
+ ]);
22
50
  /**
23
51
  * The module's *primary* text prop, or null when it has none.
24
52
  *
@@ -42,11 +70,105 @@ export function resolveInlineTextProp(config) {
42
70
  return null;
43
71
  return { name, html: definition.type === 'richText' };
44
72
  }
73
+ /**
74
+ * EVERY text-typed prop a module declares, in declaration order.
75
+ *
76
+ * This is the config-side half of the #145 safety guard: a module with more than
77
+ * one of these can never be bound at the wrapper, because the wrapper renders
78
+ * all of them and a commit would concatenate them into whichever one was bound.
79
+ */
80
+ export function listInlineTextProps(config) {
81
+ if (!config)
82
+ return [];
83
+ if (NOT_INLINE_EDITABLE.has(config.name))
84
+ return [];
85
+ const entries = Object.entries(config.props ?? {});
86
+ return entries
87
+ .filter(([, definition]) => TEXT_PROP_TYPES.has(definition.type))
88
+ .map(([name, definition]) => ({ name, html: definition.type === 'richText' }));
89
+ }
90
+ /** The prop anchors inside `wrapper` that name a text prop this module declares. */
91
+ function anchorsWithin(wrapper, byName) {
92
+ const found = [];
93
+ // `Array.from`, not `for…of`: the package's `lib` is ["ES2022", "DOM"]
94
+ // without "DOM.Iterable".
95
+ for (const el of Array.from(wrapper.querySelectorAll(`[${INLINE_PROP_ATTR}]`))) {
96
+ const prop = byName.get(el.getAttribute(INLINE_PROP_ATTR) ?? '');
97
+ if (prop)
98
+ found.push({ element: el, prop });
99
+ }
100
+ return found;
101
+ }
102
+ /**
103
+ * Which element + prop a double-click inside `wrapper` binds — or null when
104
+ * there is nothing safe to bind, in which case the caller routes the gesture to
105
+ * the props-rail fallback (kywi-cms#118) rather than to the wrapper.
106
+ *
107
+ * The rules, in order (kywi-cms#145):
108
+ *
109
+ * 1. The anchor the gesture actually landed in. Double-clicking a card's body
110
+ * edits the body, its footer edits the footer — the prop the owner pointed
111
+ * at, not "the module's first prop".
112
+ * 2. Otherwise, an unambiguous single anchor in the module: a gesture anywhere
113
+ * on a module with only one editable element can only have meant that one.
114
+ * Two or more anchors and a gesture that landed on neither is genuinely
115
+ * ambiguous → fallback.
116
+ * 3. Otherwise (a custom module with no anchors at all): the legacy whole-
117
+ * wrapper binding, and ONLY when the module declares exactly one text prop —
118
+ * the case where "everything the wrapper renders" and "the bound prop" cannot
119
+ * disagree. This is what keeps heading/text/richText/button working for
120
+ * module packs that never adopt the anchors.
121
+ *
122
+ * Anything else fails closed. A wrapper binding on a multi-text-prop module is
123
+ * the #145 corruption itself, so it is not reachable from here by any path.
124
+ */
125
+ export function resolveInlineBinding(config, wrapper, target) {
126
+ const props = listInlineTextProps(config);
127
+ if (props.length === 0)
128
+ return null;
129
+ const byName = new Map(props.map(p => [p.name, p]));
130
+ // 1 — the anchor under the pointer.
131
+ if (target && (target === wrapper || wrapper.contains(target))) {
132
+ const anchor = target.closest(`[${INLINE_PROP_ATTR}]`);
133
+ if (anchor && anchor !== wrapper && wrapper.contains(anchor)) {
134
+ const prop = byName.get(anchor.getAttribute(INLINE_PROP_ATTR) ?? '');
135
+ if (prop)
136
+ return { prop, element: anchor, kind: 'anchor' };
137
+ }
138
+ }
139
+ // 2 — exactly one anchored prop in the whole module.
140
+ const anchors = anchorsWithin(wrapper, byName);
141
+ if (anchors.length === 1) {
142
+ const only = anchors[0];
143
+ return { prop: only.prop, element: only.element, kind: 'anchor' };
144
+ }
145
+ if (anchors.length > 1)
146
+ return null;
147
+ // 3 — legacy wrapper binding, single-text-prop modules only.
148
+ //
149
+ // COUPLING (kywi-cms#145 review): this rule's safety is not `props.length === 1`
150
+ // alone — it also rests on `resolveInlineTextProp`'s "first declared prop must
151
+ // be text" rule. That is what keeps `divider` (color, then a `thickness` CSS
152
+ // length), `countdown` (targetDate, then `label`), `navMenu` (menuSlug, …) and
153
+ // `component` (slug, then `componentId`) out of wrapper binding even though
154
+ // each declares exactly one text-typed prop: the wrapper renders a computed
155
+ // value, not that prop. Reordering any module's props would silently reopen
156
+ // wrapper binding for it, so the exact wrapper-bindable set is pinned by an
157
+ // invariant test over the whole registry in `__tests__/inline-text.test.tsx`.
158
+ const primary = resolveInlineTextProp(config);
159
+ if (primary && props.length === 1)
160
+ return { prop: primary, element: wrapper, kind: 'wrapper' };
161
+ return null;
162
+ }
45
163
  /**
46
164
  * Read the edited value back out of an editing host.
47
165
  *
48
- * The host is the module's wrapper element, which also carries the editor's own
49
- * floating chrome (type chip, action cluster). Chrome is marked with
166
+ * For an anchored host (`data-kywi-prop`) the host IS the prop's element — it
167
+ * renders that prop's copy and nothing else, editor chrome included so its
168
+ * content is the value, directly.
169
+ *
170
+ * For the legacy wrapper host the host also carries the editor's own floating
171
+ * chrome (type chip, action cluster). Chrome is marked with
50
172
  * `data-kywi-editor-chrome` and skipped, so the value comes from the module's
51
173
  * own rendered element — `innerHTML` for a rich-text prop, `textContent` for a
52
174
  * plain one.
@@ -56,8 +178,9 @@ export function resolveInlineTextProp(config) {
56
178
  * shape the public page would strip anyway.
57
179
  */
58
180
  export function readInlineTextValue(host, html) {
59
- const content = Array.from(host.children).find((el) => !el.hasAttribute('data-kywi-editor-chrome'));
60
- const target = content ?? host;
181
+ const target = host.hasAttribute(INLINE_PROP_ATTR)
182
+ ? host
183
+ : (Array.from(host.children).find((el) => !el.hasAttribute('data-kywi-editor-chrome')) ?? host);
61
184
  if (html)
62
185
  return sanitizeHtml(target.innerHTML);
63
186
  // contenteditable leaves non-breaking spaces behind where the browser padded
@@ -1 +1 @@
1
- {"version":3,"file":"inline-text.js","sourceRoot":"","sources":["../../../src/admin/layout-editor/inline-text.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAGH,OAAO,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAA;AAUrD,0CAA0C;AAC1C,MAAM,eAAe,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,EAAE,UAAU,EAAE,UAAU,CAAC,CAAC,CAAA;AAEjE;;;;;GAKG;AACH,MAAM,mBAAmB,GAAG,IAAI,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAA;AAE7C;;;;;;;;GAQG;AACH,MAAM,UAAU,qBAAqB,CAAC,MAAuC;IAC3E,IAAI,CAAC,MAAM;QAAE,OAAO,IAAI,CAAA;IACxB,IAAI,mBAAmB,CAAC,GAAG,CAAC,MAAM,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAA;IACrD,MAAM,OAAO,GAAG,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,KAAK,IAAI,EAAE,CAA0C,CAAA;IAC3F,MAAM,KAAK,GAAG,OAAO,CAAC,CAAC,CAAC,CAAA;IACxB,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAA;IACvB,MAAM,CAAC,IAAI,EAAE,UAAU,CAAC,GAAG,KAAK,CAAA;IAChC,IAAI,CAAC,eAAe,CAAC,GAAG,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAA;IACtD,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,UAAU,CAAC,IAAI,KAAK,UAAU,EAAE,CAAA;AACvD,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,UAAU,mBAAmB,CAAC,IAAiB,EAAE,IAAa;IAClE,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC,IAAI,CAC5C,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,EAAE,CAAC,YAAY,CAAC,yBAAyB,CAAC,CACzB,CAAA;IAC5B,MAAM,MAAM,GAAG,OAAO,IAAI,IAAI,CAAA;IAC9B,IAAI,IAAI;QAAE,OAAO,YAAY,CAAC,MAAM,CAAC,SAAS,CAAC,CAAA;IAC/C,6EAA6E;IAC7E,4EAA4E;IAC5E,OAAO,CAAC,MAAM,CAAC,WAAW,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,SAAS,EAAE,GAAG,CAAC,CAAC,IAAI,EAAE,CAAA;AAClE,CAAC"}
1
+ {"version":3,"file":"inline-text.js","sourceRoot":"","sources":["../../../src/admin/layout-editor/inline-text.ts"],"names":[],"mappings":"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"}
@@ -1,21 +1,31 @@
1
1
  import React from 'react';
2
2
  import type { OverlayAction } from './section-overlay.js';
3
+ import type { InlineTextProp, InlineTextBindingTarget } from './inline-text.js';
3
4
  /**
4
- * Inline text editing for one module (kywi-cms#94). The overlay makes its own
5
- * wrapper which contains the module's real rendered element contenteditable
6
- * so the owner types on the page, in the page's styling. Commit reads the value
5
+ * Inline text editing for one module (kywi-cms#94, kywi-cms#145). The overlay
6
+ * makes the element that renders ONE prop contenteditablethe real `<h3>` a
7
+ * card's title is drawn in — so the owner types on the page, in the page's
8
+ * styling, and a commit can only ever write that prop. Commit reads the value
7
9
  * back out of the DOM (see `readInlineTextValue`); the parent turns that into an
8
10
  * `UPDATE_PROPS` dispatch, so the edit goes through history like any other.
11
+ *
12
+ * Which element that is is resolved per gesture rather than per module: see
13
+ * `resolveInlineBinding`, which the parent wires into `resolve` below.
9
14
  */
10
15
  export interface InlineTextBinding {
11
16
  /** True while THIS module is the one being text-edited. */
12
17
  active: boolean;
13
- /** Whether the bound prop holds HTML (Enter inserts a line break instead of committing). */
14
- html: boolean;
18
+ /**
19
+ * Resolve the element + prop a gesture at `target` binds, or null when there
20
+ * is nothing safe to bind — in which case the gesture falls through to
21
+ * `onInlineFallback` (the props rail) instead of editing anything
22
+ * (kywi-cms#145).
23
+ */
24
+ resolve: (wrapper: HTMLElement, target: Element | null) => InlineTextBindingTarget | null;
15
25
  /** Begin editing (double-click, or a click on an already-selected module). */
16
26
  onActivate: () => void;
17
- /** Commit the value currently in `host` and end the edit. */
18
- onCommit: (host: HTMLElement) => void;
27
+ /** Commit the value currently in `host` to `prop` and end the edit. */
28
+ onCommit: (host: HTMLElement, prop: InlineTextProp) => void;
19
29
  /** Abandon the edit and end it, leaving the stored props untouched. */
20
30
  onCancel: () => void;
21
31
  }
@@ -49,12 +59,15 @@ interface ModuleOverlayProps {
49
59
  /** Inline text editing binding, when this module carries a primary text prop. */
50
60
  inlineText?: InlineTextBinding | null;
51
61
  /**
52
- * Double-click fallback for a module with no primary text prop (kywi-cms#118).
53
- * Without a text prop to bind, double-click otherwise no-ops and reads as
54
- * "dead" this routes the gesture to the props rail instead: select the
55
- * module, then focus its first field there. Only meaningful in in-place mode
56
- * (see `inlineTextBindingFor`'s own `!inPlace` short-circuit); admin canvas
57
- * never carries `inlineEdit`, so it never gets this either.
62
+ * Double-click fallback for a gesture with nothing safe to bind
63
+ * (kywi-cms#118, widened by kywi-cms#145). That is a module with no text prop
64
+ * at all, and now also one whose text props the renderer does not anchor
65
+ * individually where the old wrapper binding was the corruption. Without it
66
+ * double-click no-ops and reads as "dead", so this routes the gesture to the
67
+ * props rail instead: select the module, then focus its first field there.
68
+ * Only meaningful in in-place mode (see `inlineTextBindingFor`'s own `!inPlace`
69
+ * short-circuit); admin canvas never carries `inlineEdit`, so it never gets
70
+ * this either.
58
71
  */
59
72
  onInlineFallback?: () => void;
60
73
  children: React.ReactNode;
@@ -1 +1 @@
1
- {"version":3,"file":"module-overlay.d.ts","sourceRoot":"","sources":["../../../src/admin/layout-editor/module-overlay.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,MAAM,OAAO,CAAA;AAGzB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AAGzD;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC,2DAA2D;IAC3D,MAAM,EAAE,OAAO,CAAA;IACf,4FAA4F;IAC5F,IAAI,EAAE,OAAO,CAAA;IACb,8EAA8E;IAC9E,UAAU,EAAE,MAAM,IAAI,CAAA;IACtB,6DAA6D;IAC7D,QAAQ,EAAE,CAAC,IAAI,EAAE,WAAW,KAAK,IAAI,CAAA;IACrC,uEAAuE;IACvE,QAAQ,EAAE,MAAM,IAAI,CAAA;CACrB;AAED,UAAU,kBAAkB;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB;;;;;sDAKkD;IAClD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,OAAO,CAAA;IACnB,QAAQ,EAAE,MAAM,IAAI,CAAA;IACpB,OAAO,CAAC,EAAE,aAAa,EAAE,CAAA;IACzB,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,iFAAiF;IACjF,UAAU,CAAC,EAAE,iBAAiB,GAAG,IAAI,CAAA;IACrC;;;;;;;OAOG;IACH,gBAAgB,CAAC,EAAE,MAAM,IAAI,CAAA;IAC7B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAC1B;AAED,wBAAgB,aAAa,CAAC,EAC5B,QAAQ,EACR,UAAU,EACV,QAAQ,EACR,KAAK,EACL,UAAU,EACV,QAAQ,EACR,OAAO,EACP,SAAS,EACT,OAAe,EACf,UAAU,EACV,UAAU,EACV,gBAAgB,EAChB,QAAQ,GACT,EAAE,kBAAkB,2CA6MpB"}
1
+ {"version":3,"file":"module-overlay.d.ts","sourceRoot":"","sources":["../../../src/admin/layout-editor/module-overlay.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,MAAM,OAAO,CAAA;AAGzB,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAA;AACzD,OAAO,KAAK,EAAE,cAAc,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAA;AAG/E;;;;;;;;;;GAUG;AACH,MAAM,WAAW,iBAAiB;IAChC,2DAA2D;IAC3D,MAAM,EAAE,OAAO,CAAA;IACf;;;;;OAKG;IACH,OAAO,EAAE,CAAC,OAAO,EAAE,WAAW,EAAE,MAAM,EAAE,OAAO,GAAG,IAAI,KAAK,uBAAuB,GAAG,IAAI,CAAA;IACzF,8EAA8E;IAC9E,UAAU,EAAE,MAAM,IAAI,CAAA;IACtB,uEAAuE;IACvE,QAAQ,EAAE,CAAC,IAAI,EAAE,WAAW,EAAE,IAAI,EAAE,cAAc,KAAK,IAAI,CAAA;IAC3D,uEAAuE;IACvE,QAAQ,EAAE,MAAM,IAAI,CAAA;CACrB;AAED,UAAU,kBAAkB;IAC1B,QAAQ,EAAE,MAAM,CAAA;IAChB,UAAU,EAAE,MAAM,CAAA;IAClB;;;;;sDAKkD;IAClD,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,6DAA6D;IAC7D,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,UAAU,EAAE,OAAO,CAAA;IACnB,QAAQ,EAAE,MAAM,IAAI,CAAA;IACpB,OAAO,CAAC,EAAE,aAAa,EAAE,CAAA;IACzB,8EAA8E;IAC9E,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB;;;;;;OAMG;IACH,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,yFAAyF;IACzF,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,iFAAiF;IACjF,UAAU,CAAC,EAAE,iBAAiB,GAAG,IAAI,CAAA;IACrC;;;;;;;;;;OAUG;IACH,gBAAgB,CAAC,EAAE,MAAM,IAAI,CAAA;IAC7B,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAC1B;AAED,wBAAgB,aAAa,CAAC,EAC5B,QAAQ,EACR,UAAU,EACV,QAAQ,EACR,KAAK,EACL,UAAU,EACV,QAAQ,EACR,OAAO,EACP,SAAS,EACT,OAAe,EACf,UAAU,EACV,UAAU,EACV,gBAAgB,EAChB,QAAQ,GACT,EAAE,kBAAkB,2CAiYpB"}
@@ -18,20 +18,131 @@ export function ModuleOverlay({ moduleId, instanceId, columnId, index, isSelecte
18
18
  // Escape must not commit: turning contenteditable off blurs the host, and the
19
19
  // blur handler would otherwise save the very edit the owner just abandoned.
20
20
  const cancellingRef = React.useRef(false);
21
+ /** The element + prop the CURRENT edit is bound to (kywi-cms#145). */
22
+ const boundRef = React.useRef(null);
23
+ // Read inside effects and event handlers, which must see the latest binding
24
+ // without re-running the editing effect (which would re-focus and re-place the
25
+ // caret) every time the parent re-creates the binding object.
26
+ const inlineTextRef = React.useRef(inlineText);
27
+ inlineTextRef.current = inlineText;
21
28
  const setRefs = React.useCallback((node) => {
22
29
  hostRef.current = node;
23
30
  setNodeRef(node);
24
31
  }, [setNodeRef]);
25
32
  const isEditing = Boolean(inlineText?.active);
26
- // Focus the editing host and drop the caret at the end of the text, so typing
27
- // continues the copy rather than replacing it.
33
+ /**
34
+ * Recovery counter for an edit whose element went away (kywi-cms#145 review).
35
+ * Bumping it re-runs the attach effect below against the DOM that is actually
36
+ * on the page now.
37
+ */
38
+ const [attachNonce, setAttachNonce] = React.useState(0);
39
+ const reattach = React.useCallback(() => setAttachNonce(n => n + 1), []);
40
+ /**
41
+ * Set once a session has failed to re-resolve and has asked to end. Without
42
+ * it the recovery effect and the attach effect would take turns forever on a
43
+ * module that genuinely has nothing left to bind.
44
+ */
45
+ const abandonedRef = React.useRef(false);
46
+ /**
47
+ * True when a live edit has no element on the page any more.
48
+ *
49
+ * The module's inner DOM can be replaced under an edit — undo emptying a
50
+ * heading swaps the renderer for the #134 empty state, a prop change re-keys
51
+ * a subtree. `contenteditable` is then sitting on a detached node: it can
52
+ * never fire `focusout`, so the edit can never commit, and the gesture
53
+ * handlers below would swallow every click as "already editing", leaving the
54
+ * module inert until a window-level Escape (kywi-cms#145 review).
55
+ */
56
+ function isOrphanedEdit() {
57
+ const bound = boundRef.current;
58
+ return !bound || !bound.element.isConnected;
59
+ }
60
+ /** Resolve + remember what a gesture at `target` would edit. */
61
+ function bind(target) {
62
+ const wrapper = hostRef.current;
63
+ const binding = inlineTextRef.current;
64
+ if (!wrapper || !binding)
65
+ return null;
66
+ const bound = binding.resolve(wrapper, target);
67
+ boundRef.current = bound;
68
+ return bound;
69
+ }
70
+ /**
71
+ * Take an orphaned session over with THIS gesture: re-resolve from where the
72
+ * pointer landed and re-attach. Returns false — and ends the session — when
73
+ * nothing resolves, so the gesture falls through to select/rail rather than
74
+ * being eaten by an edit that can no longer go anywhere.
75
+ *
76
+ * `bind` has already stored the answer by the time `reattach` runs, and the
77
+ * attach effect below is written to consume it (kywi-cms#145 review round 2):
78
+ * the pointer is the ONLY thing that can say which of a card's remaining
79
+ * anchors the owner meant, so losing it here would strand every recovery on a
80
+ * module with two or more of them.
81
+ */
82
+ function recoverOrphanedEdit(target) {
83
+ if (bind(target)) {
84
+ reattach();
85
+ return true;
86
+ }
87
+ inlineTextRef.current?.onCancel();
88
+ return false;
89
+ }
90
+ // Reset per session, ahead of the attach effect below (effects run in
91
+ // declaration order), so a session that ended in failure does not poison the
92
+ // next one.
93
+ React.useEffect(() => {
94
+ if (isEditing)
95
+ abandonedRef.current = false;
96
+ }, [isEditing]);
97
+ // Turn contenteditable on for the BOUND ELEMENT — not the wrapper, unless the
98
+ // wrapper is what resolution chose (kywi-cms#145) — then focus it and drop the
99
+ // caret at the end of the text, so typing continues the copy rather than
100
+ // replacing it. Imperative rather than a rendered `contentEditable` prop
101
+ // because the bound element belongs to the module's own renderer, not to this
102
+ // component's JSX.
28
103
  React.useEffect(() => {
29
- if (!isEditing)
104
+ if (!isEditing) {
105
+ // Where the binding is forgotten — deliberately NOT in the cleanup below
106
+ // (kywi-cms#145 review round 2). React runs a cleanup BEFORE the next
107
+ // effect body, so clearing it there also cleared it on an `attachNonce`
108
+ // re-run: `recoverOrphanedEdit` resolved a binding from the gesture, the
109
+ // cleanup threw it away, and this body fell back to resolving with no
110
+ // pointer at all — which returns null, and so CANCELS the session, on any
111
+ // module that still has two or more anchors (rule 2 is ambiguous there).
112
+ // This effect only re-runs on `isEditing`/`attachNonce`, and the nonce
113
+ // only moves while an edit is live, so reaching here means the session
114
+ // itself has ended.
115
+ boundRef.current = null;
116
+ return;
117
+ }
118
+ const wrapper = hostRef.current;
119
+ if (!wrapper)
30
120
  return;
31
- const host = hostRef.current;
32
- if (!host)
121
+ // An activation this component did not start (the parent set `editingId`
122
+ // directly) has no gesture to resolve from — resolve pointer-lessly. Same
123
+ // for a binding whose element has since left the document (kywi-cms#145
124
+ // review): re-resolve rather than attach to a node nobody can see. A
125
+ // binding a recovering gesture just resolved passes all three checks and is
126
+ // used as-is, which is the point of the recovery.
127
+ let bound = boundRef.current;
128
+ if (!bound ||
129
+ !bound.element.isConnected ||
130
+ (bound.element !== wrapper && !wrapper.contains(bound.element))) {
131
+ bound = inlineTextRef.current?.resolve(wrapper, null) ?? null;
132
+ boundRef.current = bound;
133
+ }
134
+ if (!bound) {
135
+ // Active with nothing bindable — the module re-rendered out from under an
136
+ // edit, say. End the session rather than leave the owner in an "editing"
137
+ // module that swallows nothing and cannot be typed in.
138
+ abandonedRef.current = true;
139
+ inlineTextRef.current?.onCancel();
33
140
  return;
141
+ }
142
+ const host = bound.element;
34
143
  cancellingRef.current = false;
144
+ host.setAttribute('contenteditable', 'true');
145
+ host.setAttribute('spellcheck', 'true');
35
146
  host.focus();
36
147
  const selection = window.getSelection?.();
37
148
  if (selection && typeof document.createRange === 'function') {
@@ -41,7 +152,46 @@ export function ModuleOverlay({ moduleId, instanceId, columnId, index, isSelecte
41
152
  selection.removeAllRanges();
42
153
  selection.addRange(range);
43
154
  }
44
- }, [isEditing]);
155
+ // A `<summary>` anchor — the accordion's title, the FAQ's question — keeps
156
+ // its activation behaviour while contenteditable (kywi-cms#145 review): in
157
+ // Chrome a click that only meant "put the caret here" also toggles the
158
+ // panel out from under the caret, and Space toggles as well as typing.
159
+ // Both reach the toggle as the CLICK event's default action (the keyboard
160
+ // ones through a synthesized click), so cancelling that one event stops the
161
+ // toggle while leaving caret placement (a mousedown default) and character
162
+ // insertion (a beforeinput default) alone. Deliberately not a keydown
163
+ // preventDefault: that would stop Space typing a space, which is the very
164
+ // behaviour this is here to preserve. Switching the anchor to an inner
165
+ // `<span>` is not an option — the published markup must stay byte-identical
166
+ // (layout/__tests__/inline-prop-anchors.test.tsx).
167
+ const suppressActivation = host.tagName === 'SUMMARY' ? (event) => event.preventDefault() : null;
168
+ if (suppressActivation)
169
+ host.addEventListener('click', suppressActivation);
170
+ return () => {
171
+ // Undo exactly what this run did to `host`, and nothing else. On a
172
+ // re-attach that means the OLD element goes back to being ordinary markup
173
+ // (suppressor and all, so a re-bind away from a `<summary>` stops
174
+ // swallowing its clicks) while the binding itself — which by then may be
175
+ // the new one a gesture resolved — is left alone; the session's binding is
176
+ // cleared by the `!isEditing` branch above instead.
177
+ if (suppressActivation)
178
+ host.removeEventListener('click', suppressActivation);
179
+ host.removeAttribute('contenteditable');
180
+ host.removeAttribute('spellcheck');
181
+ };
182
+ }, [isEditing, attachNonce]);
183
+ // The other half of the orphan recovery: no deps, so it runs after EVERY
184
+ // render (and after the attach effect above, which is declared first). A
185
+ // re-render that replaced the module's inner DOM lands here with a bound
186
+ // element that is no longer in the document — re-run the attach against what
187
+ // is there now (kywi-cms#145 review).
188
+ React.useEffect(() => {
189
+ if (!isEditing || abandonedRef.current)
190
+ return;
191
+ if (!isOrphanedEdit())
192
+ return;
193
+ reattach();
194
+ });
45
195
  const style = {
46
196
  transform: CSS.Transform.toString(transform),
47
197
  transition,
@@ -53,26 +203,48 @@ export function ModuleOverlay({ moduleId, instanceId, columnId, index, isSelecte
53
203
  // instead of following the link (kywi-cms#94).
54
204
  if (inPlace)
55
205
  e.preventDefault();
56
- if (isEditing)
206
+ // A live edit swallows clicks — but only a LIVE one. An orphaned session
207
+ // (see `isOrphanedEdit`) must let the gesture re-bind, or the module stays
208
+ // inert (kywi-cms#145 review).
209
+ if (isEditing) {
210
+ if (!isOrphanedEdit())
211
+ return;
212
+ if (recoverOrphanedEdit(e.target))
213
+ return;
214
+ onSelect();
57
215
  return;
216
+ }
58
217
  // A click on an already-selected, text-bearing module starts typing —
59
- // the second click of the "select, then edit" pattern (kywi-cms#94).
218
+ // the second click of the "select, then edit" pattern (kywi-cms#94). Which
219
+ // prop that is depends on where the click landed (kywi-cms#145); a click
220
+ // with nothing safe to bind just leaves the module selected, and the
221
+ // double-click that follows routes to the rail.
60
222
  if (inlineText && isSelected) {
61
- inlineText.onActivate();
223
+ if (bind(e.target))
224
+ inlineText.onActivate();
62
225
  return;
63
226
  }
64
227
  onSelect();
65
228
  }
66
229
  function handleDoubleClick(e) {
67
- if (isEditing)
68
- return;
69
- if (inlineText) {
230
+ if (isEditing) {
231
+ if (!isOrphanedEdit())
232
+ return;
233
+ if (recoverOrphanedEdit(e.target)) {
234
+ e.stopPropagation();
235
+ return;
236
+ }
237
+ // Nothing left to re-bind: the session has just ended, so fall through to
238
+ // the rail fallback below rather than eating the double-click.
239
+ }
240
+ if (inlineText && bind(e.target)) {
70
241
  e.stopPropagation();
71
242
  inlineText.onActivate();
72
243
  return;
73
244
  }
74
- // No primary text prop to bind (kywi-cms#118) — route the gesture to the
75
- // props rail instead of leaving it a silent no-op.
245
+ // Nothing safe to bind (kywi-cms#118, kywi-cms#145) — route the gesture to
246
+ // the props rail instead of leaving it a silent no-op, and never to the
247
+ // wrapper, which renders more than any one prop.
76
248
  if (onInlineFallback) {
77
249
  e.stopPropagation();
78
250
  onInlineFallback();
@@ -84,39 +256,46 @@ export function ModuleOverlay({ moduleId, instanceId, columnId, index, isSelecte
84
256
  if (e.key === 'Escape') {
85
257
  // Handled here rather than at the window: stopping propagation keeps the
86
258
  // shell's Escape handler (deselect) from also firing for the same press.
259
+ // Deliberately not gated on a resolved binding — Escape is the way out of
260
+ // an edit session however it got into trouble.
87
261
  e.preventDefault();
88
262
  e.stopPropagation();
89
263
  cancellingRef.current = true;
90
264
  inlineText.onCancel();
91
265
  return;
92
266
  }
93
- if (e.key === 'Enter' && !e.shiftKey && !inlineText.html) {
267
+ const bound = boundRef.current;
268
+ if (!bound)
269
+ return;
270
+ if (e.key === 'Enter' && !e.shiftKey && !bound.prop.html) {
94
271
  // A plain-text prop is a single line — Enter means "done", not a newline.
95
272
  e.preventDefault();
96
273
  e.stopPropagation();
97
- inlineText.onCommit(e.currentTarget);
274
+ inlineText.onCommit(bound.element, bound.prop);
98
275
  }
99
276
  }
100
277
  function handleBlur(e) {
101
- if (!isEditing || !inlineText)
278
+ const bound = boundRef.current;
279
+ if (!isEditing || !inlineText || !bound)
102
280
  return;
103
281
  // Focus moving to a child (an action button) is not leaving the edit.
282
+ // `currentTarget` is the wrapper: React's onBlur is focusout, which bubbles,
283
+ // so the edit's own element reports through here whatever it is bound to.
104
284
  if (e.relatedTarget && e.currentTarget.contains(e.relatedTarget))
105
285
  return;
106
286
  if (cancellingRef.current) {
107
287
  cancellingRef.current = false;
108
288
  return;
109
289
  }
110
- inlineText.onCommit(e.currentTarget);
290
+ inlineText.onCommit(bound.element, bound.prop);
111
291
  }
292
+ // No rendered `contentEditable` here: the edit is turned on imperatively, on
293
+ // the resolved element, by the effect above (kywi-cms#145).
112
294
  const editingProps = inlineText
113
295
  ? {
114
296
  onDoubleClick: handleDoubleClick,
115
297
  onKeyDown: handleKeyDown,
116
298
  onBlur: handleBlur,
117
- ...(isEditing
118
- ? { contentEditable: true, suppressContentEditableWarning: true, spellCheck: true }
119
- : {}),
120
299
  }
121
300
  : onInlineFallback
122
301
  ? { onDoubleClick: handleDoubleClick }