@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.
- package/LICENSE +201 -0
- package/README.md +148 -0
- package/dist/admin/bridge.d.mts +19 -0
- package/dist/admin/bridge.mjs +111 -0
- package/dist/admin/element-id.mjs +48 -0
- package/dist/admin/index.d.mts +2 -0
- package/dist/admin/index.mjs +3 -0
- package/dist/admin/reveal.mjs +108 -0
- package/dist/admin/row-button.mjs +90 -0
- package/dist/admin/rows.mjs +59 -0
- package/dist/attributes.d.mts +35 -0
- package/dist/attributes.mjs +26 -0
- package/dist/client/bridge.d.mts +23 -0
- package/dist/client/bridge.mjs +119 -0
- package/dist/client/geometry.mjs +41 -0
- package/dist/client/index.d.mts +5 -0
- package/dist/client/index.mjs +5 -0
- package/dist/client/marked.d.mts +38 -0
- package/dist/client/marked.mjs +44 -0
- package/dist/client/overlay.mjs +58 -0
- package/dist/client/target.mjs +33 -0
- package/dist/config/index.d.mts +2 -0
- package/dist/config/index.mjs +2 -0
- package/dist/config/plugin.d.mts +18 -0
- package/dist/config/plugin.mjs +73 -0
- package/dist/index.d.mts +4 -0
- package/dist/index.mjs +4 -0
- package/dist/protocol.d.mts +51 -0
- package/dist/protocol.mjs +63 -0
- package/dist/resolve-path.d.mts +46 -0
- package/dist/resolve-path.mjs +63 -0
- package/package.json +102 -0
|
@@ -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,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,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 };
|