@abinnovision/payloadcms-viewfinder 1.0.0-beta.2 → 1.0.0-beta.4
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 +34 -25
- package/dist/admin/bridge.d.mts +4 -0
- package/dist/admin/bridge.mjs +50 -5
- package/dist/admin/icons.mjs +33 -0
- package/dist/admin/row-button.mjs +3 -18
- package/dist/admin/toggle.mjs +62 -0
- package/dist/attributes.d.mts +7 -5
- package/dist/attributes.mjs +7 -5
- package/dist/client/bridge.d.mts +4 -0
- package/dist/client/bridge.mjs +17 -2
- package/dist/client/geometry.mjs +11 -8
- package/dist/client/index.d.mts +1 -2
- package/dist/client/index.mjs +1 -2
- package/dist/protocol.d.mts +15 -2
- package/dist/protocol.mjs +14 -2
- package/package.json +1 -1
- package/dist/client/marked.d.mts +0 -38
- package/dist/client/marked.mjs +0 -44
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
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
93
|
-
preview
|
|
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 {
|
|
104
|
+
import { markBlock, markField } from "@abinnovision/payloadcms-viewfinder";
|
|
97
105
|
|
|
98
|
-
|
|
99
|
-
<
|
|
100
|
-
</
|
|
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
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
```
|
package/dist/admin/bridge.d.mts
CHANGED
|
@@ -13,6 +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
21
|
declare const ViewfinderFormBridge: () => ReactNode;
|
|
18
22
|
//#endregion
|
package/dist/admin/bridge.mjs
CHANGED
|
@@ -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 {
|
|
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)
|
|
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__*/
|
|
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 {
|
|
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__*/
|
|
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 };
|
package/dist/attributes.d.mts
CHANGED
|
@@ -18,11 +18,13 @@ interface FieldMarkerAttributes {
|
|
|
18
18
|
readonly [FIELD_ATTRIBUTE]: string;
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
|
-
* Attributes identifying one block in the rendered output. Spread onto
|
|
22
|
-
* block's own root element
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
29
|
declare const markBlock: (id: string, blockType?: string) => BlockMarkerAttributes;
|
|
28
30
|
/**
|
package/dist/attributes.mjs
CHANGED
|
@@ -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
|
|
12
|
-
* block's own root element
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
package/dist/client/bridge.d.mts
CHANGED
|
@@ -17,6 +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
25
|
declare const ViewfinderBridge: (props: ViewfinderBridgeProps) => ReactNode;
|
|
22
26
|
//#endregion
|
package/dist/client/bridge.mjs
CHANGED
|
@@ -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 === "
|
|
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
|
}
|
package/dist/client/geometry.mjs
CHANGED
|
@@ -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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* all zeroes. A `Range` over its contents measures what it
|
|
9
|
-
* covering element and text children alike, which is why
|
|
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`:
|
|
31
|
-
*
|
|
32
|
-
* instead.
|
|
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({
|
package/dist/client/index.d.mts
CHANGED
|
@@ -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,
|
|
4
|
+
export { type BlockAddress, type BlockMarkerAttributes, type FieldMarkerAttributes, ViewfinderBridge, type ViewfinderBridgeProps, markBlock, markField };
|
package/dist/client/index.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { markBlock, markField } from "../attributes.mjs";
|
|
3
3
|
import { ViewfinderBridge } from "./bridge.mjs";
|
|
4
|
-
|
|
5
|
-
export { Marked, ViewfinderBridge, markBlock, markField };
|
|
4
|
+
export { ViewfinderBridge, markBlock, markField };
|
package/dist/protocol.d.mts
CHANGED
|
@@ -9,6 +9,11 @@ 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
18
|
declare const VIEWFINDER_PROTOCOL_VERSION = 1;
|
|
14
19
|
/**
|
|
@@ -28,10 +33,17 @@ 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
40
|
type PreviewMessage = AddressedEnvelope<"hover"> | AddressedEnvelope<"select"> | Envelope<"leave"> | Envelope<"ready">;
|
|
33
|
-
/**
|
|
34
|
-
|
|
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
|
+
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
48
|
declare const isPreviewMessage: (value: unknown) => value is PreviewMessage;
|
|
37
49
|
/** Narrows the untrusted `event.data` of a `message` event from the admin. */
|
|
@@ -44,6 +56,7 @@ declare const previewMessage: {
|
|
|
44
56
|
};
|
|
45
57
|
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
|
};
|
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
|
-
|
|
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
|
};
|
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.
|
|
4
|
+
"version": "1.0.0-beta.4",
|
|
5
5
|
"description": "Two-way block addressing between a rendered frontend and the Payload CMS admin form.",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"payload",
|
package/dist/client/marked.d.mts
DELETED
|
@@ -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 };
|
package/dist/client/marked.mjs
DELETED
|
@@ -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 };
|