@abinnovision/payloadcms-viewfinder 1.0.0-beta.3 → 1.0.0-beta.5

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/README.md CHANGED
@@ -10,16 +10,17 @@ rendered block, resolves that identifier back to a path in the admin's own form
10
10
  messages between the two windows. In the preview, hovering a block outlines and names it and
11
11
  clicking anywhere inside it scrolls the matching form row into view. In the admin, hovering anywhere
12
12
  in a block row outlines that block in the preview, and a button in the row header scrolls the
13
- preview to it.
13
+ preview to it. All of it is off until an editor turns it on, from one toggle in the document
14
+ controls.
14
15
 
15
16
  Inline editing is deliberately not part of it. A visual editor needs to know which DOM node belongs
16
17
  to which field before it can do anything else, and that layer is useful on its own.
17
18
  [`docs/concepts.md`](./docs/concepts.md) describes the addressing model;
18
19
  [`docs/limitations.md`](./docs/limitations.md) states what is out of scope and why.
19
20
 
20
- Viewfinder works in any Payload app. [`@abinnovision/payloadcms-montage`](../montage) is not
21
- required, but when it is present its `wrapBlock` registry option makes the whole block tree
22
- addressable in one hook. See [`docs/integration.md`](./docs/integration.md) for both paths.
21
+ Viewfinder works in any Payload app and takes no view on how blocks are rendered.
22
+ [`@abinnovision/payloadcms-montage`](../montage) is not required; a block marks itself the same way
23
+ either way. See [`docs/integration.md`](./docs/integration.md) for the wiring.
23
24
 
24
25
  ## Install
25
26
 
@@ -89,36 +90,44 @@ names it, and a click anywhere inside that block selects it. The whole block is
89
90
  plain click on a link inside a marked block selects rather than navigates while the page is framed.
90
91
  Modified and secondary clicks are left alone, so an editor can still open a link in a new tab.
91
92
 
92
- Finally, mark the blocks. Wrap each one in `<Marked>`, passing the Payload row `id` and your own
93
- preview flag:
93
+ A crosshair toggle sits in the document controls, beside Payload's own live-preview button, on any
94
+ document that has a live preview configured. It is greyed out until a preview is actually open, and
95
+ **it starts off**: a previewed page behaves like the real site until an editor asks for the
96
+ addressing, so no outline appears and a link inside a marked block navigates normally. Turning it
97
+ on lights up both directions and the row buttons. The choice is a per-user Payload preference under
98
+ the key `viewfinder`, so it follows the editor across documents and browsers.
99
+
100
+ Finally, mark the blocks. A block addresses itself by spreading `markBlock()` onto its own root
101
+ element, passing the Payload row `id`:
94
102
 
95
103
  ```tsx
96
- import { Marked } from "@abinnovision/payloadcms-viewfinder/client";
104
+ import { markBlock, markField } from "@abinnovision/payloadcms-viewfinder";
97
105
 
98
- <Marked id={block.id} blockType={block.blockType} enabled={isPreview}>
99
- <HeroModule block={block} />
100
- </Marked>;
106
+ export const HeroModule = ({ block }) => (
107
+ <section {...markBlock(block.id, block.blockType)}>
108
+ <h2 {...markField("heading")}>{block.heading}</h2>
109
+ </section>
110
+ );
101
111
  ```
102
112
 
103
- With `enabled={false}` the children render untouched, with no wrapper and no attributes, so
104
- production output is unaffected by having viewfinder installed.
113
+ Import from the package root, not `./client`. `markBlock` is a pure function that returns a plain
114
+ object, so a server component can spread it without crossing a client boundary.
105
115
 
106
- `Marked` adds nothing to the tree when its child is a DOM element: the attributes go onto that
107
- element. Only a component element, a fragment, an array, text or a promise gets a
108
- `display: contents` wrapper, since a component may not forward unknown props to any DOM node. A
109
- block can settle it either way by spreading `markBlock()` onto its own element:
116
+ Call it only when your own preview flag is on, and only for a row that has an `id`. Nothing in the
117
+ package enforces either: an unsaved row would emit an address that resolves to nothing, and an
118
+ empty `data-vf-id` still matches, shadowing the nearest real ancestor. A one-line helper is the
119
+ usual answer:
110
120
 
111
121
  ```tsx
112
- import {
113
- markBlock,
114
- markField,
115
- } from "@abinnovision/payloadcms-viewfinder/client";
116
-
117
- <section {...markBlock(block.id, block.blockType)}>
118
- <h2 {...markField("heading")}>{block.heading}</h2>
119
- </section>;
122
+ const mark = (block, isPreview) =>
123
+ isPreview && block.id ? markBlock(block.id, block.blockType) : {};
120
124
  ```
121
125
 
126
+ A block that renders no element of its own — because it returns a fragment, an array, or a
127
+ third-party component that will not forward `data-*` — needs to grow one to be addressable. Add
128
+ that element deliberately rather than reaching for `display: contents`: a real element has a real
129
+ box, which is what the overlay measures.
130
+
122
131
  `markField` is optional and opt-in. Its argument is relative to the enclosing block (`"heading"`,
123
132
  or `"items.0.label"` for something nested), never an absolute document path, which is what lets the
124
133
  address survive the block moving to a different index.
@@ -127,7 +136,7 @@ address survive the block moving to a different index.
127
136
 
128
137
  ```
129
138
  "." attributes, protocol, path resolution. Imports nothing.
130
- "./client" ViewfinderBridge, Marked, markBlock, markField
139
+ "./client" ViewfinderBridge, markBlock, markField
131
140
  "./config" viewfinderPlugin, loaded from payload.config.ts
132
141
  "./admin" ViewfinderFormBridge, mounted by the plugin through the import map
133
142
  ```
@@ -13,7 +13,10 @@ import { ReactNode } from "react";
13
13
  * Only the button scrolls. Driving the scroll from focus or from an ordinary
14
14
  * click, as an earlier version did, moved the preview while an editor was
15
15
  * merely placing a caret.
16
+ *
17
+ * All of it stands down when the editor turns the toggle off. This side owns
18
+ * that setting and tells the preview what it is, both on change and whenever
19
+ * the preview announces itself.
16
20
  */
17
- declare const ViewfinderFormBridge: () => ReactNode;
18
- //#endregion
19
- export { ViewfinderFormBridge };
21
+ export declare const ViewfinderFormBridge: () => ReactNode;
22
+ //#endregion
@@ -4,13 +4,18 @@ import { resolveAddressForPath, resolveAddressPath } from "../resolve-path.mjs";
4
4
  import { revealPath } from "./reveal.mjs";
5
5
  import { RowButton } from "./row-button.mjs";
6
6
  import { blockPaths, findRows, rowControls, rowPathAt, sameRows } from "./rows.mjs";
7
- import { Fragment, jsx } from "react/jsx-runtime";
7
+ import { ViewfinderToggle } from "./toggle.mjs";
8
+ import { Fragment, jsx, jsxs } from "react/jsx-runtime";
8
9
  import { useEffect, useMemo, useRef, useState } from "react";
9
10
  import { createPortal } from "react-dom";
10
- import { useAllFormFields } from "@payloadcms/ui";
11
+ import { useAllFormFields, useLivePreviewContext, usePreferences } from "@payloadcms/ui";
11
12
  //#region src/admin/bridge.tsx
12
13
  /** Payload renders exactly one live-preview iframe, with this id. */ const PREVIEW_IFRAME_ID = "live-preview-iframe";
13
14
  /** Long enough to coalesce a burst of admin re-renders, short enough to feel instant. */ const RESCAN_MS = 50;
15
+ /**
16
+ * Payload preference key. Holds an object rather than a bare boolean so a
17
+ * later second setting fits without a new key or a migration.
18
+ */ const PREFERENCE_KEY = "viewfinder";
14
19
  const previewIframe = () => document.getElementById(PREVIEW_IFRAME_ID);
15
20
  /**
16
21
  * Posts into the preview frame, addressed to the origin that frame is
@@ -35,18 +40,47 @@ const previewIframe = () => document.getElementById(PREVIEW_IFRAME_ID);
35
40
  * Only the button scrolls. Driving the scroll from focus or from an ordinary
36
41
  * click, as an earlier version did, moved the preview while an editor was
37
42
  * merely placing a caret.
43
+ *
44
+ * All of it stands down when the editor turns the toggle off. This side owns
45
+ * that setting and tells the preview what it is, both on change and whenever
46
+ * the preview announces itself.
38
47
  */ const ViewfinderFormBridge = () => {
39
48
  const [fields] = useAllFormFields();
49
+ const { getPreference, setPreference } = usePreferences();
50
+ const { isLivePreviewing, url: livePreviewURL } = useLivePreviewContext();
40
51
  const [rows, setRows] = useState(/* @__PURE__ */ new Map());
52
+ const [enabled, setEnabled] = useState(void 0);
41
53
  const formState = useRef(fields);
42
54
  formState.current = fields;
55
+ const enabledState = useRef(false);
56
+ enabledState.current = enabled ?? false;
57
+ const loaded = useRef(false);
58
+ useEffect(() => {
59
+ if (loaded.current) return;
60
+ loaded.current = true;
61
+ getPreference(PREFERENCE_KEY).then((preference) => {
62
+ setEnabled(preference?.enabled === true);
63
+ }).catch(() => {
64
+ setEnabled(false);
65
+ });
66
+ }, [getPreference]);
67
+ useEffect(() => {
68
+ if (enabled === void 0) return;
69
+ post(adminMessage.enabled(enabled));
70
+ if (!enabled) post(adminMessage.clear());
71
+ }, [enabled]);
43
72
  const rowPaths = useRef(/* @__PURE__ */ new Map());
44
73
  rowPaths.current = useMemo(() => new Map([...rows].map(([path, row]) => [row, path])), [rows]);
45
74
  const pathsKey = useMemo(() => blockPaths(fields).join("|"), [fields]);
46
75
  useEffect(() => {
47
76
  const onMessage = (event) => {
48
77
  if (event.source !== previewIframe()?.contentWindow) return;
49
- if (!isPreviewMessage(event.data) || event.data.type !== "select") return;
78
+ if (!isPreviewMessage(event.data)) return;
79
+ if (event.data.type === "ready") {
80
+ post(adminMessage.enabled(enabledState.current));
81
+ return;
82
+ }
83
+ if (event.data.type !== "select" || !enabledState.current) return;
50
84
  const path = resolveAddressPath(formState.current, event.data.address);
51
85
  if (path !== void 0) revealPath(document, path);
52
86
  };
@@ -78,6 +112,10 @@ const previewIframe = () => document.getElementById(PREVIEW_IFRAME_ID);
78
112
  useEffect(() => {
79
113
  let hovered;
80
114
  const highlight = (path) => {
115
+ if (!enabledState.current) {
116
+ hovered = void 0;
117
+ return;
118
+ }
81
119
  if (path === hovered) return;
82
120
  hovered = path;
83
121
  const address = path === void 0 ? void 0 : resolveAddressForPath(formState.current, path);
@@ -96,7 +134,14 @@ const previewIframe = () => document.getElementById(PREVIEW_IFRAME_ID);
96
134
  document.removeEventListener("pointerleave", onPointerLeave);
97
135
  };
98
136
  }, []);
99
- return /*#__PURE__*/ jsx(Fragment, { children: [...rows].map(([path, row]) => {
137
+ return /*#__PURE__*/ jsxs(Fragment, { children: [livePreviewURL ? /*#__PURE__*/ jsx(ViewfinderToggle, {
138
+ disabled: !isLivePreviewing || enabled === void 0,
139
+ enabled: enabled ?? false,
140
+ onToggle: (next) => {
141
+ setEnabled(next);
142
+ setPreference(PREFERENCE_KEY, { enabled: next });
143
+ }
144
+ }) : null, enabled !== true ? null : [...rows].map(([path, row]) => {
100
145
  const controls = rowControls(row);
101
146
  return controls === null ? null : /*#__PURE__*/ createPortal(/*#__PURE__*/ jsx(RowButton, {
102
147
  label: "Scroll the preview to this block",
@@ -105,7 +150,7 @@ const previewIframe = () => document.getElementById(PREVIEW_IFRAME_ID);
105
150
  if (address) post(adminMessage.scrollTo(address));
106
151
  }
107
152
  }), controls, path);
108
- }) });
153
+ })] });
109
154
  };
110
155
  //#endregion
111
156
  export { ViewfinderFormBridge };
@@ -0,0 +1,33 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ //#region src/admin/icons.tsx
4
+ /**
5
+ * The one mark viewfinder draws in the admin, shared by the row button and
6
+ * the toggle so the two read as the same feature. Stroke and size come from
7
+ * the button around it, so the icon carries no colour of its own.
8
+ */ const Crosshair = (props) => /*#__PURE__*/ jsxs("svg", {
9
+ "aria-hidden": true,
10
+ fill: "none",
11
+ height: "14",
12
+ stroke: "currentColor",
13
+ strokeWidth: "1.6",
14
+ viewBox: "0 0 16 16",
15
+ width: "14",
16
+ children: [
17
+ /*#__PURE__*/ jsx("circle", {
18
+ cx: "8",
19
+ cy: "8",
20
+ r: "3.2"
21
+ }),
22
+ /*#__PURE__*/ jsx("path", {
23
+ d: "M8 1v2.2M8 12.8V15M1 8h2.2M12.8 8H15",
24
+ strokeLinecap: "round"
25
+ }),
26
+ props.slashed ? /*#__PURE__*/ jsx("path", {
27
+ d: "M2.5 13.5 13.5 2.5",
28
+ strokeLinecap: "round"
29
+ }) : null
30
+ ]
31
+ });
32
+ //#endregion
33
+ export { Crosshair };
@@ -1,5 +1,6 @@
1
1
  "use client";
2
- import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { Crosshair } from "./icons.mjs";
3
+ import { jsx } from "react/jsx-runtime";
3
4
  import { useState } from "react";
4
5
  //#region src/admin/row-button.tsx
5
6
  /**
@@ -67,23 +68,7 @@ import { useState } from "react";
67
68
  },
68
69
  title: props.label,
69
70
  type: "button",
70
- children: /*#__PURE__*/ jsxs("svg", {
71
- "aria-hidden": true,
72
- fill: "none",
73
- height: "14",
74
- stroke: "currentColor",
75
- strokeWidth: "1.6",
76
- viewBox: "0 0 16 16",
77
- width: "14",
78
- children: [/*#__PURE__*/ jsx("circle", {
79
- cx: "8",
80
- cy: "8",
81
- r: "3.2"
82
- }), /*#__PURE__*/ jsx("path", {
83
- d: "M8 1v2.2M8 12.8V15M1 8h2.2M12.8 8H15",
84
- strokeLinecap: "round"
85
- })]
86
- })
71
+ children: /*#__PURE__*/ jsx(Crosshair, {})
87
72
  });
88
73
  };
89
74
  //#endregion
@@ -0,0 +1,62 @@
1
+ "use client";
2
+ import { Crosshair } from "./icons.mjs";
3
+ import { jsx } from "react/jsx-runtime";
4
+ import { Button } from "@payloadcms/ui";
5
+ //#region src/admin/toggle.tsx
6
+ /**
7
+ * The same box in both states, because it is the box Payload's live-preview
8
+ * toggler already draws beside it: `subtle` resolves to `--theme-elevation-100`
9
+ * on `--theme-elevation-200`, which is that button exactly. State is carried by
10
+ * the icon, as it is on the toggler, whose eye slashes rather than reboxing.
11
+ *
12
+ * Not `secondary`, which is the one that looks wrong here: its border is
13
+ * `--theme-elevation-800`, so an outlined icon button lands as a black square
14
+ * next to Payload's grey ones.
15
+ */ const STYLE = "subtle";
16
+ /**
17
+ * `Button`'s own sizes are shaped for a label, so an icon-only one comes out a
18
+ * squat rectangle. These are the toggler's own dimensions, in the toggler's own
19
+ * units, which is what makes the two read as one pair of square icon buttons
20
+ * rather than a square beside an oblong.
21
+ */ const SQUARE = {
22
+ width: "calc(var(--base) * 1.6)",
23
+ height: "calc(var(--base) * 1.6)",
24
+ padding: 0,
25
+ display: "inline-flex",
26
+ alignItems: "center",
27
+ justifyContent: "center"
28
+ };
29
+ const LABEL = {
30
+ on: "Stop linking the preview and this form",
31
+ off: "Link the preview and this form",
32
+ idle: "Open the live preview to link it to this form"
33
+ };
34
+ /**
35
+ * The one control for the whole feature. Off means no outlines in either
36
+ * direction, no row buttons, and no click interception in the preview, so an
37
+ * editor can click through the previewed site the way a visitor would.
38
+ *
39
+ * Built on Payload's own `Button` rather than a styled `<button>`, so the
40
+ * hover, focus and disabled states are the admin's rather than an imitation of
41
+ * them, and stay right through a theme or a Payload upgrade.
42
+ */ const ViewfinderToggle = (props) => {
43
+ const label = props.disabled ? LABEL.idle : props.enabled ? LABEL.on : LABEL.off;
44
+ return /*#__PURE__*/ jsx(Button, {
45
+ "aria-label": label,
46
+ buttonStyle: STYLE,
47
+ disabled: props.disabled,
48
+ extraButtonProps: {
49
+ "aria-pressed": props.enabled,
50
+ style: SQUARE
51
+ },
52
+ icon: /*#__PURE__*/ jsx(Crosshair, { slashed: !props.enabled }),
53
+ margin: false,
54
+ onClick: () => {
55
+ props.onToggle(!props.enabled);
56
+ },
57
+ size: "small",
58
+ tooltip: label
59
+ });
60
+ };
61
+ //#endregion
62
+ export { ViewfinderToggle };
@@ -5,31 +5,32 @@
5
5
  * against its own form state. That is what keeps the frontend free of a
6
6
  * content source map and the API unchanged.
7
7
  */
8
- declare const BLOCK_ID_ATTRIBUTE = "data-vf-id";
8
+ export declare const BLOCK_ID_ATTRIBUTE = "data-vf-id";
9
9
  /** Advisory only: the admin addresses blocks by id, never by type. */
10
- declare const BLOCK_TYPE_ATTRIBUTE = "data-vf-type";
10
+ export declare const BLOCK_TYPE_ATTRIBUTE = "data-vf-type";
11
11
  /** Resolved against the nearest marked block ancestor, so it stays index-free. */
12
- declare const FIELD_ATTRIBUTE = "data-vf-field";
13
- interface BlockMarkerAttributes {
12
+ export declare const FIELD_ATTRIBUTE = "data-vf-field";
13
+ export interface BlockMarkerAttributes {
14
14
  readonly [BLOCK_ID_ATTRIBUTE]: string;
15
15
  readonly [BLOCK_TYPE_ATTRIBUTE]?: string;
16
16
  }
17
- interface FieldMarkerAttributes {
17
+ export interface FieldMarkerAttributes {
18
18
  readonly [FIELD_ATTRIBUTE]: string;
19
19
  }
20
20
  /**
21
- * Attributes identifying one block in the rendered output. Spread onto a
22
- * block's own root element to skip the `<Marked>` wrapper, which is worth
23
- * doing wherever the block already renders a stable element: a real element
24
- * has a real box, so the highlight overlay does not have to infer geometry
25
- * from children.
21
+ * Attributes identifying one block in the rendered output. Spread onto the
22
+ * block's own root element, and only when the page is a preview: an address
23
+ * is a preview affordance, not something a visitor's markup needs.
24
+ *
25
+ * A block with no root element of its own has to grow one to be addressable.
26
+ * That element is worth adding deliberately, since a real element has a real
27
+ * box and the highlight overlay measures it directly.
26
28
  */
27
- declare const markBlock: (id: string, blockType?: string) => BlockMarkerAttributes;
29
+ export declare const markBlock: (id: string, blockType?: string) => BlockMarkerAttributes;
28
30
  /**
29
31
  * Attributes identifying one field within the enclosing block. `field` is
30
32
  * relative to that block (`"heading"`, or `"items.0.label"` for something
31
33
  * nested), never an absolute document path.
32
34
  */
33
- declare const markField: (field: string) => FieldMarkerAttributes;
34
- //#endregion
35
- export { BLOCK_ID_ATTRIBUTE, BLOCK_TYPE_ATTRIBUTE, BlockMarkerAttributes, FIELD_ATTRIBUTE, FieldMarkerAttributes, markBlock, markField };
35
+ export declare const markField: (field: string) => FieldMarkerAttributes;
36
+ //#endregion
@@ -8,11 +8,13 @@
8
8
  /** Advisory only: the admin addresses blocks by id, never by type. */ const BLOCK_TYPE_ATTRIBUTE = "data-vf-type";
9
9
  /** Resolved against the nearest marked block ancestor, so it stays index-free. */ const FIELD_ATTRIBUTE = "data-vf-field";
10
10
  /**
11
- * Attributes identifying one block in the rendered output. Spread onto a
12
- * block's own root element to skip the `<Marked>` wrapper, which is worth
13
- * doing wherever the block already renders a stable element: a real element
14
- * has a real box, so the highlight overlay does not have to infer geometry
15
- * from children.
11
+ * Attributes identifying one block in the rendered output. Spread onto the
12
+ * block's own root element, and only when the page is a preview: an address
13
+ * is a preview affordance, not something a visitor's markup needs.
14
+ *
15
+ * A block with no root element of its own has to grow one to be addressable.
16
+ * That element is worth adding deliberately, since a real element has a real
17
+ * box and the highlight overlay measures it directly.
16
18
  */ const markBlock = (id, blockType) => blockType === void 0 ? { [BLOCK_ID_ATTRIBUTE]: id } : {
17
19
  [BLOCK_ID_ATTRIBUTE]: id,
18
20
  [BLOCK_TYPE_ATTRIBUTE]: blockType
@@ -1,6 +1,6 @@
1
1
  import { ReactNode } from "react";
2
2
  //#region src/client/bridge.d.ts
3
- interface ViewfinderBridgeProps {
3
+ export interface ViewfinderBridgeProps {
4
4
  /**
5
5
  * Origin of the Payload admin, e.g. `https://cms.example.com`. Required
6
6
  * rather than defaulting to `"*"`: this window posts the ids of everything
@@ -17,7 +17,10 @@ interface ViewfinderBridgeProps {
17
17
  *
18
18
  * Does nothing at all when the page is not framed, so the same tree can be
19
19
  * served to real visitors without a second code path.
20
+ *
21
+ * The admin owns the on/off setting and announces it. Until it does, and
22
+ * whenever it says off, this is an untouched page: no outline, and a link
23
+ * inside a marked block navigates the way it would for a visitor.
20
24
  */
21
- declare const ViewfinderBridge: (props: ViewfinderBridgeProps) => ReactNode;
22
- //#endregion
23
- export { ViewfinderBridge, ViewfinderBridgeProps };
25
+ export declare const ViewfinderBridge: (props: ViewfinderBridgeProps) => ReactNode;
26
+ //#endregion
@@ -5,7 +5,7 @@ import { measureElement, scrollBoxIntoView } from "./geometry.mjs";
5
5
  import { Overlay } from "./overlay.mjs";
6
6
  import { resolveTarget } from "./target.mjs";
7
7
  import { jsx } from "react/jsx-runtime";
8
- import { useEffect, useState } from "react";
8
+ import { useEffect, useRef, useState } from "react";
9
9
  //#region src/client/bridge.tsx
10
10
  const findBlock = (id) => document.querySelector(`[${BLOCK_ID_ATTRIBUTE}="${CSS.escape(id)}"]`);
11
11
  const labelFor = (address) => address.field === void 0 ? address.blockType ?? "block" : `${address.blockType ?? "block"} · ${address.field}`;
@@ -18,10 +18,15 @@ const labelFor = (address) => address.field === void 0 ? address.blockType ?? "b
18
18
  *
19
19
  * Does nothing at all when the page is not framed, so the same tree can be
20
20
  * served to real visitors without a second code path.
21
+ *
22
+ * The admin owns the on/off setting and announces it. Until it does, and
23
+ * whenever it says off, this is an untouched page: no outline, and a link
24
+ * inside a marked block navigates the way it would for a visitor.
21
25
  */ const ViewfinderBridge = (props) => {
22
26
  const { adminOrigin } = props;
23
27
  const [active, setActive] = useState(null);
24
28
  const [box, setBox] = useState(void 0);
29
+ const enabled = useRef(false);
25
30
  useEffect(() => {
26
31
  if (window.parent === window) return;
27
32
  const post = (message) => {
@@ -35,6 +40,7 @@ const labelFor = (address) => address.field === void 0 ? address.blockType ?? "b
35
40
  post(address ? previewMessage.hover(address) : previewMessage.leave());
36
41
  };
37
42
  const onPointerOver = (event) => {
43
+ if (!enabled.current) return;
38
44
  const resolved = resolveTarget(event.target);
39
45
  if (!resolved) {
40
46
  setActive(null);
@@ -48,6 +54,7 @@ const labelFor = (address) => address.field === void 0 ? address.blockType ?? "b
48
54
  postHover(resolved.address);
49
55
  };
50
56
  const onClick = (event) => {
57
+ if (!enabled.current) return;
51
58
  if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
52
59
  const resolved = resolveTarget(event.target);
53
60
  if (!resolved) return;
@@ -62,7 +69,15 @@ const labelFor = (address) => address.field === void 0 ? address.blockType ?? "b
62
69
  const onMessage = (event) => {
63
70
  if (event.origin !== adminOrigin || event.source !== window.parent) return;
64
71
  if (!isAdminMessage(event.data)) return;
65
- if (event.data.type === "clear") {
72
+ if (event.data.type === "enabled") {
73
+ enabled.current = event.data.enabled;
74
+ if (!event.data.enabled) {
75
+ setActive(null);
76
+ hovered = null;
77
+ }
78
+ return;
79
+ }
80
+ if (!enabled.current || event.data.type === "clear") {
66
81
  setActive(null);
67
82
  return;
68
83
  }
@@ -3,11 +3,13 @@ const isEmpty = (rect) => rect.width === 0 && rect.height === 0;
3
3
  /**
4
4
  * Viewport-relative box for a marked element.
5
5
  *
6
- * `<Marked>` wraps blocks in a `display: contents` element so that layout is
7
- * untouched, and such an element generates no box of its own its rect is
8
- * all zeroes. A `Range` over its contents measures what it actually renders,
9
- * covering element and text children alike, which is why this is a range
10
- * rather than a walk over `children`.
6
+ * A marked element usually has a box of its own and is measured directly.
7
+ * Not always: an element that is `display: contents` generates no box at all,
8
+ * and its rect is all zeroes. A `Range` over its contents measures what it
9
+ * actually renders, covering element and text children alike, which is why
10
+ * this is a range rather than a walk over `children`. The range is an
11
+ * inference — an absolutely positioned or transformed child contributes its
12
+ * own rect, so the box can come out larger or offset.
11
13
  */ const measureElement = (element) => {
12
14
  const own = element.getBoundingClientRect();
13
15
  const rect = isEmpty(own) ? rangeRect(element) : own;
@@ -27,9 +29,10 @@ const rangeRect = (element) => {
27
29
  /**
28
30
  * Centres a measured box in the viewport.
29
31
  *
30
- * Not `Element.scrollIntoView`: a `display: contents` wrapper has no box for
31
- * the browser to scroll to, so the already-measured box is scrolled to
32
- * instead. That keeps wrapped and self-marked blocks behaving identically.
32
+ * Not `Element.scrollIntoView`: an element with no box of its own gives the
33
+ * browser nothing to scroll to, so the already-measured box is scrolled to
34
+ * instead. Every marked block then behaves the same way, whether or not it
35
+ * generates a box.
33
36
  */ const scrollBoxIntoView = (view, box) => {
34
37
  const top = view.scrollY + box.top - (view.innerHeight - box.height) / 2;
35
38
  view.scrollTo({
@@ -1,5 +1,4 @@
1
1
  import { ViewfinderBridge, ViewfinderBridgeProps } from "./bridge.mjs";
2
- import { Marked, MarkedProps } from "./marked.mjs";
3
2
  import { BlockMarkerAttributes, FieldMarkerAttributes, markBlock, markField } from "../attributes.mjs";
4
3
  import { BlockAddress } from "../protocol.mjs";
5
- export { type BlockAddress, type BlockMarkerAttributes, type FieldMarkerAttributes, Marked, type MarkedProps, ViewfinderBridge, type ViewfinderBridgeProps, markBlock, markField };
4
+ export { type BlockAddress, type BlockMarkerAttributes, type FieldMarkerAttributes, ViewfinderBridge, type ViewfinderBridgeProps, markBlock, markField };
@@ -1,5 +1,4 @@
1
1
  "use client";
2
2
  import { markBlock, markField } from "../attributes.mjs";
3
3
  import { ViewfinderBridge } from "./bridge.mjs";
4
- import { Marked } from "./marked.mjs";
5
- export { Marked, ViewfinderBridge, markBlock, markField };
4
+ export { ViewfinderBridge, markBlock, markField };
@@ -1,6 +1,6 @@
1
1
  import { Plugin } from "payload";
2
2
  //#region src/config/plugin.d.ts
3
- interface ViewfinderPluginArgs {
3
+ export interface ViewfinderPluginArgs {
4
4
  /** Collection slugs to make addressable. Defaults to every collection. */
5
5
  collections?: string[] | undefined;
6
6
  /** Global slugs to make addressable. Defaults to every global. */
@@ -13,6 +13,5 @@ interface ViewfinderPluginArgs {
13
13
  * enabling it for a collection that has no live preview configured costs
14
14
  * nothing beyond the component itself.
15
15
  */
16
- declare const viewfinderPlugin: (args?: ViewfinderPluginArgs) => Plugin;
17
- //#endregion
18
- export { ViewfinderPluginArgs, viewfinderPlugin };
16
+ export declare const viewfinderPlugin: (args?: ViewfinderPluginArgs) => Plugin;
17
+ //#endregion
@@ -5,17 +5,22 @@
5
5
  * carries a source tag and a version, and is validated structurally on
6
6
  * arrival rather than cast.
7
7
  */
8
- declare const VIEWFINDER_SOURCE = "viewfinder";
8
+ export declare const VIEWFINDER_SOURCE = "viewfinder";
9
9
  /**
10
10
  * Bumped only on a breaking envelope change. A mismatched version is dropped
11
11
  * silently, so a stale frontend deployment cannot drive a newer admin.
12
+ *
13
+ * Adding a message type is not a breaking change and does not qualify: a
14
+ * receiver that predates the type does not have it in its own type set, so it
15
+ * drops the message and keeps behaving as it did. Bumping the version for that
16
+ * would instead break every existing pairing at once.
12
17
  */
13
- declare const VIEWFINDER_PROTOCOL_VERSION = 1;
18
+ export declare const VIEWFINDER_PROTOCOL_VERSION = 1;
14
19
  /**
15
20
  * What one message points at. `field` is relative to the block, which is what
16
21
  * lets the same address survive the block moving to a different index.
17
22
  */
18
- interface BlockAddress {
23
+ export interface BlockAddress {
19
24
  id: string;
20
25
  blockType?: string;
21
26
  field?: string;
@@ -28,24 +33,31 @@ interface Envelope<TType extends string> {
28
33
  interface AddressedEnvelope<TType extends string> extends Envelope<TType> {
29
34
  address: BlockAddress;
30
35
  }
36
+ interface FlaggedEnvelope<TType extends string> extends Envelope<TType> {
37
+ enabled: boolean;
38
+ }
31
39
  /** Sent by the rendered page, in the iframe, up to the admin. */
32
- type PreviewMessage = AddressedEnvelope<"hover"> | AddressedEnvelope<"select"> | Envelope<"leave"> | Envelope<"ready">;
33
- /** Sent by the admin down into the preview iframe. */
34
- type AdminMessage = AddressedEnvelope<"highlight"> | AddressedEnvelope<"scrollTo"> | Envelope<"clear">;
40
+ export type PreviewMessage = AddressedEnvelope<"hover"> | AddressedEnvelope<"select"> | Envelope<"leave"> | Envelope<"ready">;
41
+ /**
42
+ * Sent by the admin down into the preview iframe. `enabled` is the admin
43
+ * answering for the whole feature: the admin owns the setting, and the preview
44
+ * is told what it is, on connect and on every change.
45
+ */
46
+ export type AdminMessage = AddressedEnvelope<"highlight"> | AddressedEnvelope<"scrollTo"> | Envelope<"clear"> | FlaggedEnvelope<"enabled">;
35
47
  /** Narrows the untrusted `event.data` of a `message` event from the iframe. */
36
- declare const isPreviewMessage: (value: unknown) => value is PreviewMessage;
48
+ export declare const isPreviewMessage: (value: unknown) => value is PreviewMessage;
37
49
  /** Narrows the untrusted `event.data` of a `message` event from the admin. */
38
- declare const isAdminMessage: (value: unknown) => value is AdminMessage;
39
- declare const previewMessage: {
50
+ export declare const isAdminMessage: (value: unknown) => value is AdminMessage;
51
+ export declare const previewMessage: {
40
52
  readonly ready: () => PreviewMessage;
41
53
  readonly leave: () => PreviewMessage;
42
54
  readonly hover: (address: BlockAddress) => PreviewMessage;
43
55
  readonly select: (address: BlockAddress) => PreviewMessage;
44
56
  };
45
- declare const adminMessage: {
57
+ export declare const adminMessage: {
46
58
  readonly clear: () => AdminMessage;
59
+ readonly enabled: (enabled: boolean) => AdminMessage;
47
60
  readonly highlight: (address: BlockAddress) => AdminMessage;
48
61
  readonly scrollTo: (address: BlockAddress) => AdminMessage;
49
62
  };
50
- //#endregion
51
- export { AdminMessage, BlockAddress, PreviewMessage, VIEWFINDER_PROTOCOL_VERSION, VIEWFINDER_SOURCE, adminMessage, isAdminMessage, isPreviewMessage, previewMessage };
63
+ //#endregion
package/dist/protocol.mjs CHANGED
@@ -8,6 +8,11 @@
8
8
  /**
9
9
  * Bumped only on a breaking envelope change. A mismatched version is dropped
10
10
  * silently, so a stale frontend deployment cannot drive a newer admin.
11
+ *
12
+ * Adding a message type is not a breaking change and does not qualify: a
13
+ * receiver that predates the type does not have it in its own type set, so it
14
+ * drops the message and keeps behaving as it did. Bumping the version for that
15
+ * would instead break every existing pairing at once.
11
16
  */ const VIEWFINDER_PROTOCOL_VERSION = 1;
12
17
  const PREVIEW_TYPES = /* @__PURE__ */ new Set([
13
18
  "hover",
@@ -18,7 +23,8 @@ const PREVIEW_TYPES = /* @__PURE__ */ new Set([
18
23
  const ADMIN_TYPES = /* @__PURE__ */ new Set([
19
24
  "highlight",
20
25
  "scrollTo",
21
- "clear"
26
+ "clear",
27
+ "enabled"
22
28
  ]);
23
29
  const ADDRESSED_TYPES = /* @__PURE__ */ new Set([
24
30
  "hover",
@@ -26,6 +32,7 @@ const ADDRESSED_TYPES = /* @__PURE__ */ new Set([
26
32
  "highlight",
27
33
  "scrollTo"
28
34
  ]);
35
+ const FLAGGED_TYPES = /* @__PURE__ */ new Set(["enabled"]);
29
36
  const isAddress = (value) => {
30
37
  if (typeof value !== "object" || value === null) return false;
31
38
  const candidate = value;
@@ -35,7 +42,8 @@ const isEnvelope = (value, types) => {
35
42
  if (typeof value !== "object" || value === null) return false;
36
43
  const candidate = value;
37
44
  if (candidate["source"] !== "viewfinder" || candidate["version"] !== 1 || typeof candidate["type"] !== "string" || !types.has(candidate["type"])) return false;
38
- return !ADDRESSED_TYPES.has(candidate["type"]) || isAddress(candidate["address"]);
45
+ if (ADDRESSED_TYPES.has(candidate["type"]) && !isAddress(candidate["address"])) return false;
46
+ return !FLAGGED_TYPES.has(candidate["type"]) || typeof candidate["enabled"] === "boolean";
39
47
  };
40
48
  /** Narrows the untrusted `event.data` of a `message` event from the iframe. */ const isPreviewMessage = (value) => isEnvelope(value, PREVIEW_TYPES);
41
49
  /** Narrows the untrusted `event.data` of a `message` event from the admin. */ const isAdminMessage = (value) => isEnvelope(value, ADMIN_TYPES);
@@ -56,6 +64,10 @@ const previewMessage = {
56
64
  };
57
65
  const adminMessage = {
58
66
  clear: () => bare("clear"),
67
+ enabled: (enabled) => ({
68
+ ...bare("enabled"),
69
+ enabled
70
+ }),
59
71
  highlight: (address) => addressed("highlight", address),
60
72
  scrollTo: (address) => addressed("scrollTo", address)
61
73
  };
@@ -10,7 +10,7 @@ import { BlockAddress } from "./protocol.mjs";
10
10
  * know the collection's schema. If a Payload upgrade changes the key shape,
11
11
  * this file is the only one that has to move.
12
12
  */
13
- type FormStateLike = Readonly<Record<string, {
13
+ export type FormStateLike = Readonly<Record<string, {
14
14
  value?: unknown;
15
15
  } | undefined>>;
16
16
  /**
@@ -20,14 +20,14 @@ type FormStateLike = Readonly<Record<string, {
20
20
  * shallowest path wins so the result stays deterministic rather than
21
21
  * depending on key order.
22
22
  */
23
- declare const resolveBlockPath: (formState: FormStateLike, id: string) => string | undefined;
23
+ export declare const resolveBlockPath: (formState: FormStateLike, id: string) => string | undefined;
24
24
  /** Joins a block path and a block-relative field name into a form path. */
25
- declare const resolveFieldPath: (blockPath: string, field: string) => string;
25
+ export declare const resolveFieldPath: (blockPath: string, field: string) => string;
26
26
  /**
27
27
  * Resolves a whole address to the form path the admin should reveal: the
28
28
  * block itself, or a field inside it when the preview named one.
29
29
  */
30
- declare const resolveAddressPath: (formState: FormStateLike, address: BlockAddress) => string | undefined;
30
+ export declare const resolveAddressPath: (formState: FormStateLike, address: BlockAddress) => string | undefined;
31
31
  /**
32
32
  * The inverse, for the admin-to-preview direction: given any form path, finds
33
33
  * the id of the nearest enclosing block.
@@ -36,11 +36,10 @@ declare const resolveAddressPath: (formState: FormStateLike, address: BlockAddre
36
36
  * what distinguishes a block row from a plain array row — array rows also
37
37
  * carry an `id`, but the preview knows nothing about them.
38
38
  */
39
- declare const resolveBlockIdForPath: (formState: FormStateLike, path: string) => string | undefined;
39
+ export declare const resolveBlockIdForPath: (formState: FormStateLike, path: string) => string | undefined;
40
40
  /**
41
41
  * The address to send into the preview for a form path, carrying the field
42
42
  * suffix when the path pointed inside a block rather than at it.
43
43
  */
44
- declare const resolveAddressForPath: (formState: FormStateLike, path: string) => BlockAddress | undefined;
45
- //#endregion
46
- export { FormStateLike, resolveAddressForPath, resolveAddressPath, resolveBlockIdForPath, resolveBlockPath, resolveFieldPath };
44
+ export declare const resolveAddressForPath: (formState: FormStateLike, path: string) => BlockAddress | undefined;
45
+ //#endregion
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "@abinnovision/payloadcms-viewfinder",
4
- "version": "1.0.0-beta.3",
4
+ "version": "1.0.0-beta.5",
5
5
  "description": "Two-way block addressing between a rendered frontend and the Payload CMS admin form.",
6
6
  "keywords": [
7
7
  "payload",
@@ -68,20 +68,20 @@
68
68
  "@abinnovision/prettier-config": "^2.2.0",
69
69
  "@arethetypeswrong/core": "^0.18.5",
70
70
  "@payloadcms/ui": "3.88.0",
71
- "@swc/core": "^1.16.1",
72
- "@types/node": "^26.3.0",
71
+ "@swc/core": "^1.16.2",
72
+ "@types/node": "^26.5.1",
73
73
  "@types/react": "^19.2.18",
74
- "@vitest/coverage-v8": "^4.1.10",
75
- "eslint": "^10.9.1",
74
+ "@vitest/coverage-v8": "^5.0.0",
75
+ "eslint": "^10.10.0",
76
76
  "payload": "3.88.0",
77
77
  "prettier": "^3.9.5",
78
78
  "publint": "^0.3.24",
79
79
  "react": "^19.2.8",
80
80
  "react-dom": "^19.2.8",
81
- "tsdown": "^0.22.14",
81
+ "tsdown": "^0.23.0",
82
82
  "typescript": "^6.0.3",
83
- "unplugin-swc": "^1.5.11",
84
- "vitest": "^4.1.10"
83
+ "unplugin-swc": "^1.6.0",
84
+ "vitest": "^5.0.0"
85
85
  },
86
86
  "peerDependencies": {
87
87
  "@payloadcms/ui": ">=3.88.0 <4",
@@ -1,38 +0,0 @@
1
- import { ReactNode } from "react";
2
- //#region src/client/marked.d.ts
3
- interface MarkedProps {
4
- /** The Payload row id of this block. */
5
- id: string;
6
- /**
7
- * Shown in the preview overlay so editors can tell blocks apart.
8
- *
9
- * Explicitly `| undefined` so that under `exactOptionalPropertyTypes` a
10
- * caller can forward a possibly-absent value straight through, which is
11
- * what a generic wrapper around a block registry has to do.
12
- */
13
- blockType?: string | undefined;
14
- /**
15
- * Gate this on your own preview flag. When false the children render
16
- * untouched, with no wrapper and no attributes, so production output is
17
- * unaffected by having viewfinder installed.
18
- */
19
- enabled?: boolean | undefined;
20
- children: ReactNode;
21
- }
22
- /**
23
- * Makes one block addressable from the admin.
24
- *
25
- * Marks the block's own root element where there is one, and falls back to a
26
- * `display: contents` wrapper where there is not. The wrapper preserves
27
- * layout but is still an element in the tree: it breaks `>` and
28
- * `:nth-child()` selectors aimed at the block, the HTML parser reparents it
29
- * out of a table or a paragraph, and it has no box, so the overlay measures a
30
- * range over its children rather than a rect.
31
- *
32
- * A block can also spread `markBlock()` onto its own element and skip this
33
- * component altogether. That is the same outcome as the marked branch here,
34
- * stated by the block rather than inferred.
35
- */
36
- declare const Marked: (props: MarkedProps) => ReactNode;
37
- //#endregion
38
- export { Marked, MarkedProps };
@@ -1,44 +0,0 @@
1
- "use client";
2
- import { markBlock } from "../attributes.mjs";
3
- import { jsx } from "react/jsx-runtime";
4
- import { cloneElement, isValidElement } from "react";
5
- //#region src/client/marked.tsx
6
- /**
7
- * `display: contents` removes the wrapper from layout entirely, so marking a
8
- * block cannot change how it renders. The cost is that the wrapper has no box
9
- * of its own; `measureElement` handles that.
10
- */ const CONTENTS = { display: "contents" };
11
- /**
12
- * Whether this is a DOM element, as opposed to a component element, a
13
- * fragment, a promise or text.
14
- *
15
- * `typeof type === "string"` is what makes marking it safe: a host element
16
- * puts every unknown prop into the markup, so the attributes are certain to
17
- * land. A component element might forward them to its root or drop them, and
18
- * there is no way to tell which from here.
19
- */ const isHostElement = (node) => /*#__PURE__*/ isValidElement(node) && typeof node.type === "string";
20
- /**
21
- * Makes one block addressable from the admin.
22
- *
23
- * Marks the block's own root element where there is one, and falls back to a
24
- * `display: contents` wrapper where there is not. The wrapper preserves
25
- * layout but is still an element in the tree: it breaks `>` and
26
- * `:nth-child()` selectors aimed at the block, the HTML parser reparents it
27
- * out of a table or a paragraph, and it has no box, so the overlay measures a
28
- * range over its children rather than a rect.
29
- *
30
- * A block can also spread `markBlock()` onto its own element and skip this
31
- * component altogether. That is the same outcome as the marked branch here,
32
- * stated by the block rather than inferred.
33
- */ const Marked = (props) => {
34
- if (props.enabled === false || props.id.length === 0) return props.children;
35
- const attributes = markBlock(props.id, props.blockType);
36
- if (isHostElement(props.children)) return props.children.props["data-vf-id"] === void 0 ? /*#__PURE__*/ cloneElement(props.children, { ...attributes }) : props.children;
37
- return /*#__PURE__*/ jsx("div", {
38
- style: CONTENTS,
39
- ...attributes,
40
- children: props.children
41
- });
42
- };
43
- //#endregion
44
- export { Marked };