@ohhwells/bridge 0.1.144 → 0.1.146

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.
package/dist/index.d.cts CHANGED
@@ -28,6 +28,14 @@ interface AiBlockNode {
28
28
  align?: 'left' | 'center' | 'right';
29
29
  slots?: Record<string, unknown>;
30
30
  children?: AiBlockNode[];
31
+ /** Stable id for ordered insertion. Omitted on trees saved before slots. */
32
+ ref?: string;
33
+ /** Fixed slot inside a split (`left`/`right`) or grid (`cell-N`). Omit to keep index order. */
34
+ slot?: string;
35
+ /** Unfilled fixed slots on a group. The published page draws none of these. */
36
+ emptySlots?: string[];
37
+ /** Stack wrapped around a slot that was filled a second time. */
38
+ promoted?: true;
31
39
  /** Shape is per-block-type, defined by the backend's `BlockDef.itemFields` (accordion's
32
40
  * question/answer, media-strip's assetId/alt/href/linkTarget, footer link-list/hours/social-row). */
33
41
  items?: Array<Record<string, unknown>>;
package/dist/index.d.ts CHANGED
@@ -28,6 +28,14 @@ interface AiBlockNode {
28
28
  align?: 'left' | 'center' | 'right';
29
29
  slots?: Record<string, unknown>;
30
30
  children?: AiBlockNode[];
31
+ /** Stable id for ordered insertion. Omitted on trees saved before slots. */
32
+ ref?: string;
33
+ /** Fixed slot inside a split (`left`/`right`) or grid (`cell-N`). Omit to keep index order. */
34
+ slot?: string;
35
+ /** Unfilled fixed slots on a group. The published page draws none of these. */
36
+ emptySlots?: string[];
37
+ /** Stack wrapped around a slot that was filled a second time. */
38
+ promoted?: true;
31
39
  /** Shape is per-block-type, defined by the backend's `BlockDef.itemFields` (accordion's
32
40
  * question/answer, media-strip's assetId/alt/href/linkTarget, footer link-list/hours/social-row). */
33
41
  items?: Array<Record<string, unknown>>;
package/dist/index.js CHANGED
@@ -2440,6 +2440,49 @@ function socialHrefKey(item) {
2440
2440
  function socialIconKey(item) {
2441
2441
  return item.querySelector(ICON_SELECTOR)?.dataset.ohwKey ?? null;
2442
2442
  }
2443
+ var SOCIAL_PLATFORM_LABELS = {
2444
+ instagram: "Instagram",
2445
+ facebook: "Facebook",
2446
+ x: "X",
2447
+ twitter: "X",
2448
+ tiktok: "TikTok",
2449
+ youtube: "YouTube",
2450
+ linkedin: "LinkedIn",
2451
+ pinterest: "Pinterest",
2452
+ whatsapp: "WhatsApp",
2453
+ telegram: "Telegram",
2454
+ spotify: "Spotify",
2455
+ threads: "Threads",
2456
+ vimeo: "Vimeo",
2457
+ discord: "Discord",
2458
+ medium: "Medium",
2459
+ website: "Website"
2460
+ };
2461
+ function socialPlatformLabel(item, content) {
2462
+ const iconKey = socialIconKey(item);
2463
+ const chosen = iconKey ? (content[socialPlatformKey(iconKey)] ?? "").trim().toLowerCase() : "";
2464
+ if (chosen) return SOCIAL_PLATFORM_LABELS[chosen] ?? chosen.charAt(0).toUpperCase() + chosen.slice(1);
2465
+ const label = socialLabelElement(item);
2466
+ const words = label && !label.hasAttribute(SOCIALS_EMPTY_LABEL_ATTR) ? (label.textContent ?? "").replace(/\s+/g, " ").trim() : "";
2467
+ if (words) return words;
2468
+ const link = item.matches("a") ? item : item.querySelector("a");
2469
+ const aria = (link?.getAttribute("aria-label") ?? item.getAttribute("aria-label") ?? "").trim();
2470
+ if (aria) return aria;
2471
+ const href = (link?.getAttribute("href") ?? "").trim();
2472
+ if (/^https?:\/\//i.test(href)) {
2473
+ let host = "";
2474
+ try {
2475
+ host = new URL(href).hostname.replace(/^www\./i, "").toLowerCase();
2476
+ } catch {
2477
+ host = "";
2478
+ }
2479
+ if (host.includes("x.com") || host.includes("twitter.com")) return "X";
2480
+ for (const [id, name] of Object.entries(SOCIAL_PLATFORM_LABELS)) {
2481
+ if (host.includes(id)) return name;
2482
+ }
2483
+ }
2484
+ return "Social";
2485
+ }
2443
2486
  var SOCIALS_ORDER_KEY = "__ohw_socials_order";
2444
2487
  function fromMarkup(markup) {
2445
2488
  const holder = document.createElement("div");
@@ -6121,7 +6164,9 @@ function applyTreeToState(state, payload) {
6121
6164
  ...selfEdit ? priorEntry?.beforeSection ? { beforeSection: priorEntry.beforeSection } : {} : insertBefore && payload.targetSectionId ? { beforeSection: payload.targetSectionId } : {},
6122
6165
  ...selfEdit ? priorEntry?.replaces ? { replaces: priorEntry.replaces } : {} : payload.mode === "replace" && payload.targetSectionId ? { replaces: payload.targetSectionId } : {},
6123
6166
  tree: payload.tree,
6124
- media: payload.media ?? {},
6167
+ // A column insert rebuilds the media map from the live DOM keys. The photo
6168
+ // still points at the ref it was generated with. Keep that ref.
6169
+ media: { ...priorEntry?.media ?? {}, ...payload.media ?? {} },
6125
6170
  ...payload.brand && typeof payload.brand === "object" ? { brand: payload.brand } : {}
6126
6171
  };
6127
6172
  const others = state.sections.filter((existing) => existing.id !== entry.id);
@@ -9622,6 +9667,46 @@ function FooterSocialRowBlock({
9622
9667
  }
9623
9668
  );
9624
9669
  }
9670
+ function EmptySlotPlaceholder({ name }) {
9671
+ return /* @__PURE__ */ jsx(
9672
+ "div",
9673
+ {
9674
+ "data-ai-slot": name,
9675
+ "data-ai-slot-empty": "",
9676
+ "aria-hidden": "true",
9677
+ style: {
9678
+ minHeight: 88,
9679
+ borderRadius: 12,
9680
+ border: "1px dashed #D6D3D1",
9681
+ display: "flex",
9682
+ alignItems: "center",
9683
+ justifyContent: "center",
9684
+ padding: 16,
9685
+ color: "#A8A29E",
9686
+ fontSize: 13,
9687
+ lineHeight: 1.4,
9688
+ textAlign: "center",
9689
+ pointerEvents: "none"
9690
+ },
9691
+ children: "Ask AI to add something here"
9692
+ }
9693
+ );
9694
+ }
9695
+ function splitSides(node) {
9696
+ const named = /* @__PURE__ */ new Map();
9697
+ const unnamed = [];
9698
+ (node.children ?? []).forEach((child, index) => {
9699
+ if (child.slot === "left" || child.slot === "right") {
9700
+ named.set(child.slot, { node: child, index });
9701
+ } else {
9702
+ unnamed.push({ node: child, index });
9703
+ }
9704
+ });
9705
+ return {
9706
+ left: named.get("left") ?? unnamed.shift() ?? null,
9707
+ right: named.get("right") ?? unnamed.shift() ?? null
9708
+ };
9709
+ }
9625
9710
  function renderNode(node, ctx, path, ambientAlign) {
9626
9711
  const slots = node.slots ?? {};
9627
9712
  switch (node.type) {
@@ -9629,9 +9714,11 @@ function renderNode(node, ctx, path, ambientAlign) {
9629
9714
  return /* @__PURE__ */ jsx(TextBlock, { slots, ctx, path, align: node.align ?? ambientAlign });
9630
9715
  // Layout container: arranges child blocks, contributes no content of its own. `grid` is a
9631
9716
  // nested 12-column grid the children span (a collage is 3–5 media on it, bottom-aligned so
9632
- // mixed aspects read as a composition); `split` is exactly two children at a ratio; `stack`
9633
- // is a column. Children render through this same dispatcher, so edit markers, media
9634
- // resolution, and copy paths all work unchanged inside a group.
9717
+ // mixed aspects read as a composition); `split` is left and right (index order when `slot`
9718
+ // is omitted); `stack` is a column. A missing split side is a canvas placeholder only when
9719
+ // `emptySlots` names it. A one-child split used to keep a row at span 12 (a testimonial
9720
+ // under a heading) sits centered at half the row width. Children render through this same
9721
+ // dispatcher, so edit markers, media resolution, and copy paths all work unchanged.
9635
9722
  case "group": {
9636
9723
  const layout = str(slots.layout);
9637
9724
  const gap = slots.spacing === "tight" ? AI_TREE_TOKENS.spacing3 : slots.spacing === "airy" ? AI_TREE_TOKENS.spacing8 : AI_TREE_TOKENS.spacing6;
@@ -9729,24 +9816,38 @@ function renderNode(node, ctx, path, ambientAlign) {
9729
9816
  i
9730
9817
  ));
9731
9818
  if (layout === "grid") {
9732
- return /* @__PURE__ */ jsx(
9819
+ const holes = ctx.keyFor ? node.emptySlots ?? [] : [];
9820
+ return /* @__PURE__ */ jsxs(
9733
9821
  "div",
9734
9822
  {
9735
9823
  "data-ai-group": "grid",
9736
9824
  style: { display: "grid", gridTemplateColumns: "repeat(12, 1fr)", gap, alignItems: "end" },
9737
- children: kids
9825
+ children: [
9826
+ kids,
9827
+ holes.map((name) => /* @__PURE__ */ jsx("div", { style: { gridColumn: "span 6", minWidth: 0 }, children: /* @__PURE__ */ jsx(EmptySlotPlaceholder, { name }) }, name))
9828
+ ]
9738
9829
  }
9739
9830
  );
9740
9831
  }
9741
9832
  if (layout === "split") {
9833
+ const { left, right } = splitSides(node);
9834
+ const open = new Set(ctx.keyFor ? node.emptySlots ?? [] : []);
9835
+ const side = (entry, name) => entry ? /* @__PURE__ */ jsx("div", { style: { minWidth: 0 }, children: renderNode(entry.node, ctx, `${path}.c${entry.index}`) }, name) : open.has(name) ? /* @__PURE__ */ jsx(EmptySlotPlaceholder, { name }, name) : null;
9836
+ if (!left && !open.has("left") || !right && !open.has("right")) {
9837
+ const only = left ?? right;
9838
+ return only ? /* @__PURE__ */ jsx("div", { "data-ai-group": "split", style: { display: "flex", justifyContent: "center" }, children: /* @__PURE__ */ jsx("div", { style: { width: "50%", minWidth: 0 }, children: renderNode(only.node, ctx, `${path}.c${only.index}`) }) }) : null;
9839
+ }
9742
9840
  const ratio = str(slots.ratio);
9743
9841
  const cols = ratio === "3:5" ? "3fr 5fr" : ratio === "5:3" ? "5fr 3fr" : "1fr 1fr";
9744
- return /* @__PURE__ */ jsx(
9842
+ return /* @__PURE__ */ jsxs(
9745
9843
  "div",
9746
9844
  {
9747
9845
  "data-ai-group": "split",
9748
9846
  style: { display: "grid", gridTemplateColumns: cols, gap, alignItems: "center" },
9749
- children: kids
9847
+ children: [
9848
+ side(left, "left"),
9849
+ side(right, "right")
9850
+ ]
9750
9851
  }
9751
9852
  );
9752
9853
  }
@@ -25087,36 +25188,72 @@ function OhhwellsBridge() {
25087
25188
  const [selectedMedia, setSelectedMedia] = useState18(null);
25088
25189
  const selectedMediaElRef = useRef14(null);
25089
25190
  const mediaKeyDropTokenRef = useRef14(0);
25191
+ const selectedIconElRef = useRef14(null);
25192
+ const selectedSocialElRef = useRef14(null);
25193
+ const queueSectionKeyDrop = useCallback12((prev) => {
25194
+ const sectionEl = prev?.closest("[data-ohw-section]") ?? null;
25195
+ if (!sectionEl) return;
25196
+ const sectionInstanceId = instanceIdOf(movableUnit(sectionEl)) || null;
25197
+ if (!sectionInstanceId) return;
25198
+ const token = ++mediaKeyDropTokenRef.current;
25199
+ const onQueueDrained = (e) => {
25200
+ if (e.source !== window || e.data?.type !== "ow:media-key-drop" || e.data.token !== token) return;
25201
+ window.removeEventListener("message", onQueueDrained);
25202
+ if (aiSectionApiRef.current?.getSelectedId() !== sectionInstanceId) return;
25203
+ if (selectedMediaElRef.current || selectedIconElRef.current || selectedSocialElRef.current) return;
25204
+ postToParentRef.current({
25205
+ type: "ow:section-selected",
25206
+ sectionId: sectionEl.dataset.ohwSection ?? null,
25207
+ sectionLabel: sectionEl.dataset.ohwSectionLabel ?? titleCaseSectionId(sectionEl.dataset.ohwSection ?? ""),
25208
+ key: null
25209
+ });
25210
+ };
25211
+ window.addEventListener("message", onQueueDrained);
25212
+ window.postMessage({ type: "ow:media-key-drop", token }, "*");
25213
+ }, []);
25090
25214
  const clearMediaSelection = useCallback12(() => {
25091
25215
  const prev = selectedMediaElRef.current;
25092
25216
  selectedMediaElRef.current = null;
25093
25217
  setSelectedMedia(null);
25094
- const sectionEl = prev?.closest("[data-ohw-section]") ?? null;
25095
- if (sectionEl) {
25096
- const sectionInstanceId = instanceIdOf(movableUnit(sectionEl)) || null;
25097
- if (!sectionInstanceId) return;
25098
- const token = ++mediaKeyDropTokenRef.current;
25099
- const onQueueDrained = (e) => {
25100
- if (e.source !== window || e.data?.type !== "ow:media-key-drop" || e.data.token !== token) return;
25101
- window.removeEventListener("message", onQueueDrained);
25102
- if (aiSectionApiRef.current?.getSelectedId() !== sectionInstanceId) return;
25103
- if (selectedMediaElRef.current) return;
25104
- postToParentRef.current({
25105
- type: "ow:section-selected",
25106
- sectionId: sectionEl.dataset.ohwSection ?? null,
25107
- sectionLabel: sectionEl.dataset.ohwSectionLabel ?? titleCaseSectionId(sectionEl.dataset.ohwSection ?? ""),
25108
- key: null
25109
- });
25110
- };
25111
- window.addEventListener("message", onQueueDrained);
25112
- window.postMessage({ type: "ow:media-key-drop", token }, "*");
25113
- }
25114
- }, []);
25218
+ queueSectionKeyDrop(prev);
25219
+ }, [queueSectionKeyDrop]);
25115
25220
  const clearMediaSelectionRef = useRef14(clearMediaSelection);
25116
25221
  clearMediaSelectionRef.current = clearMediaSelection;
25222
+ const clearIconSelection = useCallback12(() => {
25223
+ const prev = selectedIconElRef.current;
25224
+ if (!prev) return;
25225
+ selectedIconElRef.current = null;
25226
+ queueSectionKeyDrop(prev);
25227
+ }, [queueSectionKeyDrop]);
25228
+ const clearIconSelectionRef = useRef14(clearIconSelection);
25229
+ clearIconSelectionRef.current = clearIconSelection;
25230
+ const clearSocialSelection = useCallback12(() => {
25231
+ const prev = selectedSocialElRef.current;
25232
+ if (!prev) return;
25233
+ selectedSocialElRef.current = null;
25234
+ queueSectionKeyDrop(prev);
25235
+ }, [queueSectionKeyDrop]);
25236
+ const clearSocialSelectionRef = useRef14(clearSocialSelection);
25237
+ clearSocialSelectionRef.current = clearSocialSelection;
25238
+ const selectIconElement = useCallback12((el) => {
25239
+ if (selectedMediaElRef.current) clearMediaSelectionRef.current();
25240
+ selectedIconElRef.current = el;
25241
+ const sectionEl = el.closest("[data-ohw-section]");
25242
+ aiSectionApiRef.current?.selectFromElement(el, { report: false });
25243
+ postToParentRef.current({
25244
+ type: "ow:section-selected",
25245
+ sectionId: sectionEl?.dataset.ohwSection ?? null,
25246
+ sectionLabel: sectionEl ? sectionEl.dataset.ohwSectionLabel ?? titleCaseSectionId(sectionEl.dataset.ohwSection ?? "") : null,
25247
+ key: el.dataset.ohwKey ?? null,
25248
+ keyLabel: "Icon"
25249
+ });
25250
+ }, []);
25251
+ const selectIconElementRef = useRef14(selectIconElement);
25252
+ selectIconElementRef.current = selectIconElement;
25117
25253
  const selectMediaElement = useCallback12((el) => {
25118
25254
  const r2 = el.getBoundingClientRect();
25119
25255
  const video = el.dataset.ohwEditable === "video" ? el.querySelector("video") : null;
25256
+ selectedIconElRef.current = null;
25120
25257
  selectedMediaElRef.current = el;
25121
25258
  setSelectedMedia({
25122
25259
  key: el.dataset.ohwKey ?? "",
@@ -25770,6 +25907,7 @@ function OhhwellsBridge() {
25770
25907
  selectedElRef.current = null;
25771
25908
  selectedHrefKeyRef.current = null;
25772
25909
  selectedFooterColAttrRef.current = null;
25910
+ clearSocialSelectionRef.current();
25773
25911
  setSelectedIsCta(false);
25774
25912
  setSelectedIsSocial(false);
25775
25913
  setSelectedIsSocialsRow(false);
@@ -26671,7 +26809,22 @@ function OhhwellsBridge() {
26671
26809
  const select = useCallback12((anchor) => {
26672
26810
  if (!isNavigationItem2(anchor)) return;
26673
26811
  if (activeElRef.current) deactivate();
26674
- aiSectionApiRef.current?.selectFromElement(anchor);
26812
+ clearSocialSelectionRef.current();
26813
+ const socialItem = getSocialItem(anchor);
26814
+ if (socialItem) {
26815
+ selectedSocialElRef.current = socialItem;
26816
+ const sectionEl = socialItem.closest("[data-ohw-section]");
26817
+ aiSectionApiRef.current?.selectFromElement(anchor, { report: false });
26818
+ postToParentRef.current({
26819
+ type: "ow:section-selected",
26820
+ sectionId: sectionEl?.dataset.ohwSection ?? null,
26821
+ sectionLabel: sectionEl ? sectionEl.dataset.ohwSectionLabel ?? titleCaseSectionId(sectionEl.dataset.ohwSection ?? "") : null,
26822
+ key: socialIconKey(socialItem) ?? socialItem.getAttribute("data-ohw-href-key"),
26823
+ keyLabel: socialPlatformLabel(socialItem, editContentRef.current)
26824
+ });
26825
+ } else {
26826
+ aiSectionApiRef.current?.selectFromElement(anchor);
26827
+ }
26675
26828
  selectedElRef.current = anchor;
26676
26829
  selectedHrefKeyRef.current = anchor.getAttribute("data-ohw-href-key");
26677
26830
  selectedFooterColAttrRef.current = null;
@@ -27809,8 +27962,11 @@ function OhhwellsBridge() {
27809
27962
  display: none !important;
27810
27963
  }
27811
27964
  /* Body text (no item-action toolbar) \u2014 first click enters text edit \u2192 I-beam.
27812
- Exclude select-first nav/footer labels so grab/default on [data-ohw-href-key] can win. */
27813
- [data-ohw-editable]:not([contenteditable]):not([data-ohw-editable="image"]):not([data-ohw-editable="bg-image"]):not([data-ohw-editable="video"]):not([data-ohw-editable="link"]):not(:is([data-ohw-href-key] *)) {
27965
+ Exclude select-first nav/footer labels so grab/default on [data-ohw-href-key] can win.
27966
+ Icons are excluded too: a glyph is replaced by a click, never typed into (OHH-623),
27967
+ so it takes the picker's pointer below, not an I-beam \u2014 this blanket !important used
27968
+ to beat the stylesheet's own non-!important icon rule (OHH-718). */
27969
+ [data-ohw-editable]:not([contenteditable]):not([data-ohw-editable="image"]):not([data-ohw-editable="bg-image"]):not([data-ohw-editable="video"]):not([data-ohw-editable="link"]):not([data-ohw-editable="icon"]):not(:is([data-ohw-href-key] *)) {
27814
27970
  cursor: text !important;
27815
27971
  }
27816
27972
  /* Select-first items: default arrow; grab only when drag-start is allowed (data-ohw-can-drag). */
@@ -27831,6 +27987,7 @@ function OhhwellsBridge() {
27831
27987
  [data-ohw-editable="video"], [data-ohw-editable="video"] *,
27832
27988
  [data-ohw-editable="bg-image"], [data-ohw-editable="bg-image"] * { cursor: pointer !important; }
27833
27989
  [data-ohw-editable="link"], [data-ohw-editable="link"] * { cursor: pointer !important; }
27990
+ [data-ohw-editable="icon"], [data-ohw-editable="icon"] * { cursor: pointer !important; }
27834
27991
  /* A text/plain editable nested inside a media editable (e.g. a placeholder's fallback
27835
27992
  label inside its bg-image box) is its own click target, not the media picker \u2014 the
27836
27993
  blanket media cursor rule above would otherwise show pointer where a click actually
@@ -27982,6 +28139,9 @@ function OhhwellsBridge() {
27982
28139
  if (selectedMediaElRef.current && !selectedMediaElRef.current.contains(target) && !target.closest("[data-ohw-media-overlay], [data-ohw-edit-chrome], [data-ohw-item-interaction]")) {
27983
28140
  clearMediaSelectionRef.current();
27984
28141
  }
28142
+ if (selectedIconElRef.current && !selectedIconElRef.current.contains(target)) {
28143
+ clearIconSelectionRef.current();
28144
+ }
27985
28145
  if (selectedMediaElRef.current && target.closest("[data-ohw-media-overlay]") && !findClosestButtonLike(target)) {
27986
28146
  const hit = findImageAtPoint(e.clientX, e.clientY, false);
27987
28147
  if (hit && hit !== selectedMediaElRef.current) {
@@ -28159,7 +28319,7 @@ function OhhwellsBridge() {
28159
28319
  if (isIconEditable(editable) && !getSocialItem(editable)) {
28160
28320
  e.preventDefault();
28161
28321
  e.stopPropagation();
28162
- aiSectionApiRef.current?.selectFromElement(editable);
28322
+ selectIconElementRef.current(editable);
28163
28323
  const aiIcon = editable.hasAttribute("data-ai-icon");
28164
28324
  const featureIcon = aiIcon && /\.features\.\d+\.icon$/.test(editable.dataset.ohwKey ?? "");
28165
28325
  postToParentRef.current({
@@ -29783,6 +29943,7 @@ function OhhwellsBridge() {
29783
29943
  deactivateRef.current();
29784
29944
  deselectRef.current();
29785
29945
  clearMediaSelectionRef.current();
29946
+ clearIconSelectionRef.current();
29786
29947
  };
29787
29948
  clearInteractionChromeRef.current = clearInteractionChrome;
29788
29949
  const applyTreeToLocalState = (payload) => {
@@ -30200,6 +30361,7 @@ function OhhwellsBridge() {
30200
30361
  deselectRef.current();
30201
30362
  deactivateRef.current();
30202
30363
  clearMediaSelectionRef.current();
30364
+ clearIconSelectionRef.current();
30203
30365
  };
30204
30366
  window.addEventListener("message", handleDeactivate);
30205
30367
  const handleToastAction = (e) => {
@@ -30289,6 +30451,10 @@ function OhhwellsBridge() {
30289
30451
  clearMediaSelectionRef.current();
30290
30452
  return;
30291
30453
  }
30454
+ if (e.key === "Escape" && selectedIconElRef.current) {
30455
+ clearIconSelectionRef.current();
30456
+ return;
30457
+ }
30292
30458
  if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a" && activeElRef.current) {
30293
30459
  e.preventDefault();
30294
30460
  selectAllTextInEditable(activeElRef.current);
@@ -31310,7 +31476,7 @@ function OhhwellsBridge() {
31310
31476
  postToParent2({
31311
31477
  type: "ow:ready",
31312
31478
  version: "1",
31313
- bridgeVersion: "0.1.143",
31479
+ bridgeVersion: "0.1.145",
31314
31480
  path: pathname,
31315
31481
  nodes: collectEditableNodes(editContentRef.current),
31316
31482
  sections