@abinnovision/payloadcms-viewfinder 1.0.0-beta.1

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.
@@ -0,0 +1,90 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { useState } from "react";
4
+ //#region src/admin/row-button.tsx
5
+ /**
6
+ * Sized and shaped like Payload's own row controls, which it sits beside:
7
+ * `base(1.2)` square with a pill radius, filling with `--theme-elevation-0`
8
+ * on hover the way `.array-actions__button` does. Everything visual comes
9
+ * from Payload's variables, so it follows the active theme rather than
10
+ * sitting on top of it.
11
+ */ const BUTTON = {
12
+ pointerEvents: "auto",
13
+ position: "relative",
14
+ zIndex: 1,
15
+ order: -1,
16
+ display: "inline-flex",
17
+ alignItems: "center",
18
+ justifyContent: "center",
19
+ flex: "0 0 auto",
20
+ width: "calc(var(--base) * 1.2)",
21
+ height: "calc(var(--base) * 1.2)",
22
+ padding: 0,
23
+ border: 0,
24
+ borderRadius: "100px",
25
+ background: "transparent",
26
+ color: "currentcolor",
27
+ cursor: "pointer",
28
+ transition: "opacity 100ms linear, background-color 100ms linear"
29
+ };
30
+ /**
31
+ * The admin's half of the addressing UI: one button per block row, which
32
+ * sends the preview to that block.
33
+ *
34
+ * Portalled into Payload's own row controls, beside the row menu and the
35
+ * collapse chevron, rather than registered as the block's `Label` component.
36
+ * That slot replaces the whole header fragment, including the block-name
37
+ * input, and only reaches blocks that live in `config.blocks` — inline blocks
38
+ * passed through `blockReferences` would silently get nothing.
39
+ */ const RowButton = (props) => {
40
+ const [hovered, setHovered] = useState(false);
41
+ const [keyboardFocus, setKeyboardFocus] = useState(false);
42
+ return /*#__PURE__*/ jsx("button", {
43
+ "aria-label": props.label,
44
+ onBlur: () => {
45
+ setKeyboardFocus(false);
46
+ },
47
+ onClick: (event) => {
48
+ event.preventDefault();
49
+ event.stopPropagation();
50
+ props.onSelect();
51
+ },
52
+ onFocus: (event) => {
53
+ setKeyboardFocus(event.currentTarget.matches(":focus-visible"));
54
+ },
55
+ onMouseEnter: () => {
56
+ setHovered(true);
57
+ },
58
+ onMouseLeave: () => {
59
+ setHovered(false);
60
+ },
61
+ style: {
62
+ ...BUTTON,
63
+ opacity: hovered || keyboardFocus ? 1 : .5,
64
+ background: hovered || keyboardFocus ? "var(--theme-elevation-0)" : "transparent",
65
+ outline: keyboardFocus ? "var(--accessibility-outline)" : "none",
66
+ outlineOffset: "var(--accessibility-outline-offset)"
67
+ },
68
+ title: props.label,
69
+ 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
+ })
87
+ });
88
+ };
89
+ //#endregion
90
+ export { RowButton };
@@ -0,0 +1,59 @@
1
+ import { blockRowElementId } from "./element-id.mjs";
2
+ //#region src/admin/rows.ts
3
+ const BLOCK_TYPE_SUFFIX = ".blockType";
4
+ const COLLAPSIBLE = ".collapsible";
5
+ const ACTIONS_WRAP = ".collapsible__actions-wrap";
6
+ /** Every block path the form knows about, whether rendered or not. */ const blockPaths = (formState) => Object.keys(formState).filter((key) => key.endsWith(BLOCK_TYPE_SUFFIX)).map((key) => key.slice(0, -10)).sort();
7
+ /**
8
+ * Maps each currently rendered block row to its element.
9
+ *
10
+ * A collapsed row still renders its own header but none of its contents, so
11
+ * the rows a form has and the rows it is showing are different sets, and this
12
+ * is the second one.
13
+ */ const findRows = (doc, formState) => {
14
+ const rows = /* @__PURE__ */ new Map();
15
+ for (const path of blockPaths(formState)) {
16
+ const id = blockRowElementId(path);
17
+ const row = id === void 0 ? null : doc.getElementById(id);
18
+ if (row) rows.set(path, row);
19
+ }
20
+ return rows;
21
+ };
22
+ /**
23
+ * The controls cluster at the right of a row's header, alongside Payload's
24
+ * own row menu and collapse chevron, which is where the locate button goes.
25
+ *
26
+ * Payload puts the row id on a bare wrapper whose only child is the
27
+ * collapsible, so the row element is not itself `.collapsible`. Resolving
28
+ * that child first is what keeps this from returning a nested row's cluster.
29
+ */ const rowControls = (row) => {
30
+ const collapsible = row.querySelector(`:scope > ${COLLAPSIBLE}`);
31
+ if (!collapsible) return null;
32
+ for (const controls of collapsible.querySelectorAll(ACTIONS_WRAP)) if (controls.closest(COLLAPSIBLE) === collapsible) return controls;
33
+ return null;
34
+ };
35
+ /**
36
+ * The path of the innermost row containing `target`, if any.
37
+ *
38
+ * Walking up from the pointer's target is what makes the whole row hoverable
39
+ * rather than just its header, and it resolves nesting for free: a hero
40
+ * inside a section wrapper is found before the wrapper is, because it is
41
+ * reached first.
42
+ */ const rowPathAt = (rows, target) => {
43
+ for (let node = target; node !== null; node = node.parentElement) {
44
+ const path = rows.get(node);
45
+ if (path !== void 0) return path;
46
+ }
47
+ };
48
+ /**
49
+ * Whether two scans found the same rows in the same elements.
50
+ *
51
+ * Portalling the buttons mutates the DOM, which wakes the observer that
52
+ * triggered the scan. Without this check that loop never settles.
53
+ */ const sameRows = (a, b) => {
54
+ if (a.size !== b.size) return false;
55
+ for (const [path, element] of a) if (b.get(path) !== element) return false;
56
+ return true;
57
+ };
58
+ //#endregion
59
+ export { blockPaths, findRows, rowControls, rowPathAt, sameRows };
@@ -0,0 +1,35 @@
1
+ //#region src/attributes.d.ts
2
+ /**
3
+ * The rendered page carries no field paths. It carries the Payload row `id`
4
+ * that every block already has, and the admin resolves that back to a path
5
+ * against its own form state. That is what keeps the frontend free of a
6
+ * content source map and the API unchanged.
7
+ */
8
+ declare const BLOCK_ID_ATTRIBUTE = "data-vf-id";
9
+ /** Advisory only: the admin addresses blocks by id, never by type. */
10
+ declare const BLOCK_TYPE_ATTRIBUTE = "data-vf-type";
11
+ /** Resolved against the nearest marked block ancestor, so it stays index-free. */
12
+ declare const FIELD_ATTRIBUTE = "data-vf-field";
13
+ interface BlockMarkerAttributes {
14
+ readonly [BLOCK_ID_ATTRIBUTE]: string;
15
+ readonly [BLOCK_TYPE_ATTRIBUTE]?: string;
16
+ }
17
+ interface FieldMarkerAttributes {
18
+ readonly [FIELD_ATTRIBUTE]: string;
19
+ }
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.
26
+ */
27
+ declare const markBlock: (id: string, blockType?: string) => BlockMarkerAttributes;
28
+ /**
29
+ * Attributes identifying one field within the enclosing block. `field` is
30
+ * relative to that block (`"heading"`, or `"items.0.label"` for something
31
+ * nested), never an absolute document path.
32
+ */
33
+ declare const markField: (field: string) => FieldMarkerAttributes;
34
+ //#endregion
35
+ export { BLOCK_ID_ATTRIBUTE, BLOCK_TYPE_ATTRIBUTE, BlockMarkerAttributes, FIELD_ATTRIBUTE, FieldMarkerAttributes, markBlock, markField };
@@ -0,0 +1,26 @@
1
+ //#region src/attributes.ts
2
+ /**
3
+ * The rendered page carries no field paths. It carries the Payload row `id`
4
+ * that every block already has, and the admin resolves that back to a path
5
+ * against its own form state. That is what keeps the frontend free of a
6
+ * content source map and the API unchanged.
7
+ */ const BLOCK_ID_ATTRIBUTE = "data-vf-id";
8
+ /** Advisory only: the admin addresses blocks by id, never by type. */ const BLOCK_TYPE_ATTRIBUTE = "data-vf-type";
9
+ /** Resolved against the nearest marked block ancestor, so it stays index-free. */ const FIELD_ATTRIBUTE = "data-vf-field";
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.
16
+ */ const markBlock = (id, blockType) => blockType === void 0 ? { [BLOCK_ID_ATTRIBUTE]: id } : {
17
+ [BLOCK_ID_ATTRIBUTE]: id,
18
+ [BLOCK_TYPE_ATTRIBUTE]: blockType
19
+ };
20
+ /**
21
+ * Attributes identifying one field within the enclosing block. `field` is
22
+ * relative to that block (`"heading"`, or `"items.0.label"` for something
23
+ * nested), never an absolute document path.
24
+ */ const markField = (field) => ({ [FIELD_ATTRIBUTE]: field });
25
+ //#endregion
26
+ export { BLOCK_ID_ATTRIBUTE, BLOCK_TYPE_ATTRIBUTE, FIELD_ATTRIBUTE, markBlock, markField };
@@ -0,0 +1,23 @@
1
+ import { ReactNode } from "react";
2
+ //#region src/client/bridge.d.ts
3
+ interface ViewfinderBridgeProps {
4
+ /**
5
+ * Origin of the Payload admin, e.g. `https://cms.example.com`. Required
6
+ * rather than defaulting to `"*"`: this window posts the ids of everything
7
+ * it renders, and validates what it is told to highlight.
8
+ */
9
+ adminOrigin: string;
10
+ }
11
+ /**
12
+ * Connects the rendered page to the Payload admin that is previewing it.
13
+ * Mount once, near the root of the app.
14
+ *
15
+ * Hovering a block outlines it and names it; clicking anywhere inside it
16
+ * selects it. The whole block is the target, so there is nothing to aim at.
17
+ *
18
+ * Does nothing at all when the page is not framed, so the same tree can be
19
+ * served to real visitors without a second code path.
20
+ */
21
+ declare const ViewfinderBridge: (props: ViewfinderBridgeProps) => ReactNode;
22
+ //#endregion
23
+ export { ViewfinderBridge, ViewfinderBridgeProps };
@@ -0,0 +1,119 @@
1
+ "use client";
2
+ import { BLOCK_ID_ATTRIBUTE } from "../attributes.mjs";
3
+ import { isAdminMessage, previewMessage } from "../protocol.mjs";
4
+ import { measureElement, scrollBoxIntoView } from "./geometry.mjs";
5
+ import { Overlay } from "./overlay.mjs";
6
+ import { resolveTarget } from "./target.mjs";
7
+ import { jsx } from "react/jsx-runtime";
8
+ import { useEffect, useState } from "react";
9
+ //#region src/client/bridge.tsx
10
+ const findBlock = (id) => document.querySelector(`[${BLOCK_ID_ATTRIBUTE}="${CSS.escape(id)}"]`);
11
+ const labelFor = (address) => address.field === void 0 ? address.blockType ?? "block" : `${address.blockType ?? "block"} · ${address.field}`;
12
+ /**
13
+ * Connects the rendered page to the Payload admin that is previewing it.
14
+ * Mount once, near the root of the app.
15
+ *
16
+ * Hovering a block outlines it and names it; clicking anywhere inside it
17
+ * selects it. The whole block is the target, so there is nothing to aim at.
18
+ *
19
+ * Does nothing at all when the page is not framed, so the same tree can be
20
+ * served to real visitors without a second code path.
21
+ */ const ViewfinderBridge = (props) => {
22
+ const { adminOrigin } = props;
23
+ const [active, setActive] = useState(null);
24
+ const [box, setBox] = useState(void 0);
25
+ useEffect(() => {
26
+ if (window.parent === window) return;
27
+ const post = (message) => {
28
+ window.parent.postMessage(message, adminOrigin);
29
+ };
30
+ let hovered = null;
31
+ const postHover = (address) => {
32
+ const id = address?.id ?? null;
33
+ if (id === hovered) return;
34
+ hovered = id;
35
+ post(address ? previewMessage.hover(address) : previewMessage.leave());
36
+ };
37
+ const onPointerOver = (event) => {
38
+ const resolved = resolveTarget(event.target);
39
+ if (!resolved) {
40
+ setActive(null);
41
+ postHover(null);
42
+ return;
43
+ }
44
+ setActive({
45
+ element: resolved.element,
46
+ address: resolved.address
47
+ });
48
+ postHover(resolved.address);
49
+ };
50
+ const onClick = (event) => {
51
+ if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
52
+ const resolved = resolveTarget(event.target);
53
+ if (!resolved) return;
54
+ event.preventDefault();
55
+ event.stopPropagation();
56
+ setActive({
57
+ element: resolved.element,
58
+ address: resolved.address
59
+ });
60
+ post(previewMessage.select(resolved.address));
61
+ };
62
+ const onMessage = (event) => {
63
+ if (event.origin !== adminOrigin || event.source !== window.parent) return;
64
+ if (!isAdminMessage(event.data)) return;
65
+ if (event.data.type === "clear") {
66
+ setActive(null);
67
+ return;
68
+ }
69
+ const element = findBlock(event.data.address.id);
70
+ if (!element) return;
71
+ setActive({
72
+ element,
73
+ address: event.data.address
74
+ });
75
+ if (event.data.type === "scrollTo") {
76
+ const measured = measureElement(element);
77
+ if (measured) scrollBoxIntoView(window, measured);
78
+ }
79
+ };
80
+ document.addEventListener("pointerover", onPointerOver, { passive: true });
81
+ document.addEventListener("click", onClick, { capture: true });
82
+ window.addEventListener("message", onMessage);
83
+ post(previewMessage.ready());
84
+ return () => {
85
+ document.removeEventListener("pointerover", onPointerOver);
86
+ document.removeEventListener("click", onClick, { capture: true });
87
+ window.removeEventListener("message", onMessage);
88
+ };
89
+ }, [adminOrigin]);
90
+ useEffect(() => {
91
+ if (!active) {
92
+ setBox(void 0);
93
+ return;
94
+ }
95
+ const measure = () => {
96
+ setBox(measureElement(active.element));
97
+ };
98
+ measure();
99
+ const observer = new ResizeObserver(measure);
100
+ observer.observe(active.element);
101
+ observer.observe(document.documentElement);
102
+ window.addEventListener("scroll", measure, {
103
+ capture: true,
104
+ passive: true
105
+ });
106
+ window.addEventListener("resize", measure, { passive: true });
107
+ return () => {
108
+ observer.disconnect();
109
+ window.removeEventListener("scroll", measure, { capture: true });
110
+ window.removeEventListener("resize", measure);
111
+ };
112
+ }, [active]);
113
+ return /*#__PURE__*/ jsx(Overlay, {
114
+ box,
115
+ label: active ? labelFor(active.address) : void 0
116
+ });
117
+ };
118
+ //#endregion
119
+ export { ViewfinderBridge };
@@ -0,0 +1,41 @@
1
+ //#region src/client/geometry.ts
2
+ const isEmpty = (rect) => rect.width === 0 && rect.height === 0;
3
+ /**
4
+ * Viewport-relative box for a marked element.
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`.
11
+ */ const measureElement = (element) => {
12
+ const own = element.getBoundingClientRect();
13
+ const rect = isEmpty(own) ? rangeRect(element) : own;
14
+ if (!rect || isEmpty(rect)) return;
15
+ return {
16
+ top: rect.top,
17
+ left: rect.left,
18
+ width: rect.width,
19
+ height: rect.height
20
+ };
21
+ };
22
+ const rangeRect = (element) => {
23
+ const range = element.ownerDocument.createRange();
24
+ range.selectNodeContents(element);
25
+ return range.getBoundingClientRect();
26
+ };
27
+ /**
28
+ * Centres a measured box in the viewport.
29
+ *
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.
33
+ */ const scrollBoxIntoView = (view, box) => {
34
+ const top = view.scrollY + box.top - (view.innerHeight - box.height) / 2;
35
+ view.scrollTo({
36
+ top: Math.max(0, top),
37
+ behavior: "smooth"
38
+ });
39
+ };
40
+ //#endregion
41
+ export { measureElement, scrollBoxIntoView };
@@ -0,0 +1,5 @@
1
+ import { ViewfinderBridge, ViewfinderBridgeProps } from "./bridge.mjs";
2
+ import { Marked, MarkedProps } from "./marked.mjs";
3
+ import { BlockMarkerAttributes, FieldMarkerAttributes, markBlock, markField } from "../attributes.mjs";
4
+ import { BlockAddress } from "../protocol.mjs";
5
+ export { type BlockAddress, type BlockMarkerAttributes, type FieldMarkerAttributes, Marked, type MarkedProps, ViewfinderBridge, type ViewfinderBridgeProps, markBlock, markField };
@@ -0,0 +1,5 @@
1
+ "use client";
2
+ import { markBlock, markField } from "../attributes.mjs";
3
+ import { ViewfinderBridge } from "./bridge.mjs";
4
+ import { Marked } from "./marked.mjs";
5
+ export { Marked, ViewfinderBridge, markBlock, markField };
@@ -0,0 +1,38 @@
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 };
@@ -0,0 +1,44 @@
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 };
@@ -0,0 +1,58 @@
1
+ "use client";
2
+ import { jsx } from "react/jsx-runtime";
3
+ import { createPortal } from "react-dom";
4
+ //#region src/client/overlay.tsx
5
+ /** Lets the bridge tell its own chrome apart from the page underneath. */ const OVERLAY_ATTRIBUTE = "data-vf-overlay";
6
+ const ACCENT = "#2d81ff";
7
+ const FRAME = {
8
+ position: "fixed",
9
+ pointerEvents: "none",
10
+ zIndex: 2147483647,
11
+ outline: `2px solid ${ACCENT}`,
12
+ outlineOffset: "1px",
13
+ borderRadius: "2px",
14
+ background: "rgba(45, 129, 255, 0.08)",
15
+ transition: "top 80ms linear, left 80ms linear, width 80ms linear, height 80ms linear"
16
+ };
17
+ /**
18
+ * Names the block being pointed at. Inert on purpose: the block itself is the
19
+ * click target, so a badge that took pointer events would only carve a dead
20
+ * spot out of it.
21
+ */ const BADGE = {
22
+ position: "absolute",
23
+ top: "-1.65em",
24
+ left: 0,
25
+ display: "inline-flex",
26
+ alignItems: "center",
27
+ gap: "0.35em",
28
+ padding: "0 0.45em",
29
+ borderRadius: "2px",
30
+ background: ACCENT,
31
+ color: "#fff",
32
+ font: "500 11px/1.5em ui-sans-serif, system-ui, sans-serif",
33
+ whiteSpace: "nowrap"
34
+ };
35
+ /**
36
+ * Portalled to `document.body` so a transformed or clipping ancestor cannot
37
+ * shift the frame away from the block it is outlining — `position: fixed`
38
+ * resolves against the nearest transformed ancestor, not the viewport.
39
+ */ const Overlay = (props) => {
40
+ if (!props.box || typeof document === "undefined") return null;
41
+ const { top, left, width, height } = props.box;
42
+ return /*#__PURE__*/ createPortal(/*#__PURE__*/ jsx("div", {
43
+ [OVERLAY_ATTRIBUTE]: "",
44
+ style: {
45
+ ...FRAME,
46
+ top,
47
+ left,
48
+ width,
49
+ height
50
+ },
51
+ children: /*#__PURE__*/ jsx("span", {
52
+ style: BADGE,
53
+ children: props.label ?? "block"
54
+ })
55
+ }), document.body);
56
+ };
57
+ //#endregion
58
+ export { OVERLAY_ATTRIBUTE, Overlay };
@@ -0,0 +1,33 @@
1
+ import { BLOCK_ID_ATTRIBUTE, BLOCK_TYPE_ATTRIBUTE, FIELD_ATTRIBUTE } from "../attributes.mjs";
2
+ //#region src/client/target.ts
3
+ const BLOCK_SELECTOR = `[${BLOCK_ID_ATTRIBUTE}]`;
4
+ const FIELD_SELECTOR = `[${FIELD_ATTRIBUTE}]`;
5
+ /**
6
+ * Walks up from an event target to the block it belongs to, and to the field
7
+ * inside that block when one is marked.
8
+ *
9
+ * A field marker only counts when its own nearest block is this block. A
10
+ * block nested inside a marked field of its parent would otherwise report the
11
+ * parent's field name as its own.
12
+ *
13
+ * Generic over the concrete element type so callers get their own `Element`
14
+ * back; `closest` is declared as returning the structural type, so the one
15
+ * narrowing cast lives here rather than at every call site.
16
+ */ const resolveTarget = (target) => {
17
+ const element = target?.closest(BLOCK_SELECTOR) ?? null;
18
+ const id = element?.getAttribute(BLOCK_ID_ATTRIBUTE);
19
+ if (!element || id === null || id === void 0 || id.length === 0) return;
20
+ const blockType = element.getAttribute(BLOCK_TYPE_ATTRIBUTE);
21
+ const fieldElement = target?.closest(FIELD_SELECTOR) ?? null;
22
+ const field = fieldElement?.closest(BLOCK_SELECTOR) === element ? fieldElement.getAttribute(FIELD_ATTRIBUTE) : null;
23
+ return {
24
+ element,
25
+ address: {
26
+ id,
27
+ ...blockType === null ? {} : { blockType },
28
+ ...field === null ? {} : { field }
29
+ }
30
+ };
31
+ };
32
+ //#endregion
33
+ export { resolveTarget };
@@ -0,0 +1,2 @@
1
+ import { ViewfinderPluginArgs, viewfinderPlugin } from "./plugin.mjs";
2
+ export { type ViewfinderPluginArgs, viewfinderPlugin };
@@ -0,0 +1,2 @@
1
+ import { viewfinderPlugin } from "./plugin.mjs";
2
+ export { viewfinderPlugin };
@@ -0,0 +1,18 @@
1
+ import { Plugin } from "payload";
2
+ //#region src/config/plugin.d.ts
3
+ interface ViewfinderPluginArgs {
4
+ /** Collection slugs to make addressable. Defaults to every collection. */
5
+ collections?: string[] | undefined;
6
+ /** Global slugs to make addressable. Defaults to every global. */
7
+ globals?: string[] | undefined;
8
+ }
9
+ /**
10
+ * Makes documents addressable from their live preview.
11
+ *
12
+ * The bridge it mounts is inert until a framed page announces itself, so
13
+ * enabling it for a collection that has no live preview configured costs
14
+ * nothing beyond the component itself.
15
+ */
16
+ declare const viewfinderPlugin: (args?: ViewfinderPluginArgs) => Plugin;
17
+ //#endregion
18
+ export { ViewfinderPluginArgs, viewfinderPlugin };