@charlescms/astro 0.1.0
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 +21 -0
- package/README.md +366 -0
- package/SECURITY.md +77 -0
- package/THIRD_PARTY_NOTICES.md +56 -0
- package/connector/worker.js +505 -0
- package/connector/wrangler.toml +15 -0
- package/package.json +92 -0
- package/scripts/check-licenses.js +45 -0
- package/scripts/check-package.js +62 -0
- package/scripts/setup.js +719 -0
- package/scripts/update-vendored-site.js +71 -0
- package/src/admin.astro +314 -0
- package/src/analyzer.js +639 -0
- package/src/asset-images.js +130 -0
- package/src/astro-frontmatter.js +17 -0
- package/src/boot.js +35 -0
- package/src/client.js +347 -0
- package/src/connector-client.js +185 -0
- package/src/content-bridge.js +162 -0
- package/src/content-panel.js +440 -0
- package/src/data-analyzer.js +304 -0
- package/src/edit-affordance.js +463 -0
- package/src/editor-styles.js +243 -0
- package/src/element-editor.js +355 -0
- package/src/fields.js +6 -0
- package/src/frontmatter.js +153 -0
- package/src/ids.js +20 -0
- package/src/index.js +681 -0
- package/src/js-ast.js +140 -0
- package/src/markdown-analyzer.js +95 -0
- package/src/media-preview.js +58 -0
- package/src/panel-manager.js +133 -0
- package/src/publishing.js +457 -0
- package/src/rich-text-editor.js +209 -0
- package/src/routes.js +21 -0
- package/src/runtime-controller.js +206 -0
- package/src/sanitize.js +150 -0
- package/src/section-editor.js +437 -0
- package/src/source-edit.js +310 -0
- package/src/source-map-runtime.js +184 -0
- package/src/staged-panel.js +145 -0
- package/src/toolbar.js +128 -0
- package/src/versions-panel.js +112 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
// Provides one consistent hover/tap ring and click behavior for every editable
|
|
2
|
+
// element. It does not know how editing works; elements expose a `charlescmsEdit`
|
|
3
|
+
// action and this module chooses the right action under the pointer — preferring
|
|
4
|
+
// the most specific element, staying stable as the pointer moves, and, when several
|
|
5
|
+
// editables overlap (a logo's monogram + wordmark, text over a background image),
|
|
6
|
+
// popping a picker so the user chooses exactly instead of fighting a hover target.
|
|
7
|
+
export function createEditAffordance({ state, isVisible }) {
|
|
8
|
+
// px of slack kept around a target so a steady hand never flickers between two
|
|
9
|
+
// elements that sit close together — and so a slightly-off click still gathers
|
|
10
|
+
// the tightly-packed neighbours into the picker.
|
|
11
|
+
const HIT_PAD = 10;
|
|
12
|
+
// ms the ring lingers after the pointer leaves — long enough to travel from the
|
|
13
|
+
// element up to its label without the ring vanishing on the way.
|
|
14
|
+
const HIDE_DELAY = 260;
|
|
15
|
+
|
|
16
|
+
const area = (el) => {
|
|
17
|
+
const r = el.getBoundingClientRect();
|
|
18
|
+
return Math.max(1, r.width) * Math.max(1, r.height);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// The single element the user is pointing at is the SMALLEST editable under the
|
|
22
|
+
// point — a small price label on a big card resolves to the price, not the card.
|
|
23
|
+
function resolveEditableAt(x, y) {
|
|
24
|
+
let best = null;
|
|
25
|
+
let bestArea = Infinity;
|
|
26
|
+
for (const node of document.elementsFromPoint(x, y)) {
|
|
27
|
+
if (node.closest?.("[data-charlescms-ui]")) continue;
|
|
28
|
+
const element = node.closest?.('[data-charlescms-editable="true"]');
|
|
29
|
+
if (!element || !element.charlescmsEdit || !isVisible(element)) continue;
|
|
30
|
+
const a = area(element);
|
|
31
|
+
if (a < bestArea) { best = element; bestArea = a; }
|
|
32
|
+
}
|
|
33
|
+
return best;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Every editable at OR within a few px of the point — the candidates for the
|
|
37
|
+
// picker. Stacked elements (under the point) and tightly-packed neighbours
|
|
38
|
+
// (beside it) both qualify, so a logo's three crammed pieces all show up even on
|
|
39
|
+
// an imprecise click. Returned top-to-bottom, left-to-right (reading order).
|
|
40
|
+
function gatherCandidatesAt(x, y) {
|
|
41
|
+
const found = new Set();
|
|
42
|
+
for (const node of document.elementsFromPoint(x, y)) {
|
|
43
|
+
if (node.closest?.("[data-charlescms-ui]")) continue;
|
|
44
|
+
const element = node.closest?.('[data-charlescms-editable="true"]');
|
|
45
|
+
if (element?.charlescmsEdit && isVisible(element)) found.add(element);
|
|
46
|
+
}
|
|
47
|
+
for (const element of document.querySelectorAll('[data-charlescms-editable="true"]')) {
|
|
48
|
+
if (found.has(element) || !element.charlescmsEdit || element.closest("[data-charlescms-ui]") || !isVisible(element)) continue;
|
|
49
|
+
const r = element.getBoundingClientRect();
|
|
50
|
+
if (x >= r.left - HIT_PAD && x <= r.right + HIT_PAD && y >= r.top - HIT_PAD && y <= r.bottom + HIT_PAD) found.add(element);
|
|
51
|
+
}
|
|
52
|
+
const sortReading = (list) => list.sort((a, b) => {
|
|
53
|
+
const ra = a.getBoundingClientRect();
|
|
54
|
+
const rb = b.getBoundingClientRect();
|
|
55
|
+
return Math.abs(ra.top - rb.top) > 6 ? ra.top - rb.top : ra.left - rb.left;
|
|
56
|
+
}).slice(0, 7);
|
|
57
|
+
const list = [...found];
|
|
58
|
+
if (list.length > 1) {
|
|
59
|
+
// Keep only genuinely COMPARABLE targets. A far-larger container (a full-bleed
|
|
60
|
+
// hero behind small text or a fixed header) shouldn't turn every click into a
|
|
61
|
+
// picker — the picker is for things that actually compete for the same spot.
|
|
62
|
+
// You still edit a background by clicking its own bare area, where it's the
|
|
63
|
+
// sole candidate.
|
|
64
|
+
const minArea = Math.min(...list.map(area));
|
|
65
|
+
const comparable = list.filter((el) => area(el) <= minArea * 12);
|
|
66
|
+
if (comparable.length) return sortReading(comparable);
|
|
67
|
+
}
|
|
68
|
+
return sortReading(list);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function withinPadded(element, x, y, pad = HIT_PAD) {
|
|
72
|
+
const r = element.getBoundingClientRect();
|
|
73
|
+
return x >= r.left - pad && x <= r.right + pad && y >= r.top - pad && y <= r.bottom + pad;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function installEditAffordance() {
|
|
77
|
+
if (state.affordanceInstalled) return;
|
|
78
|
+
state.affordanceInstalled = true;
|
|
79
|
+
|
|
80
|
+
// A quiet hover ring only — no floating "Edit" label. The hand cursor already
|
|
81
|
+
// signals "clickable", and a click edits directly (or opens the picker when
|
|
82
|
+
// several editables overlap), so a separate edit button is just noise.
|
|
83
|
+
const spot = document.createElement("div");
|
|
84
|
+
spot.dataset.charlescmsUi = "true";
|
|
85
|
+
spot.className = "charlescms-spot";
|
|
86
|
+
spot.style.display = "none";
|
|
87
|
+
document.body.append(spot);
|
|
88
|
+
|
|
89
|
+
let current = null;
|
|
90
|
+
let hideTimer;
|
|
91
|
+
const hideNow = () => {
|
|
92
|
+
clearTimeout(hideTimer);
|
|
93
|
+
spot.style.display = "none";
|
|
94
|
+
current = null;
|
|
95
|
+
};
|
|
96
|
+
const hideSoon = () => {
|
|
97
|
+
clearTimeout(hideTimer);
|
|
98
|
+
hideTimer = setTimeout(hideNow, HIDE_DELAY);
|
|
99
|
+
};
|
|
100
|
+
state.hideEditSpot = hideNow;
|
|
101
|
+
|
|
102
|
+
const place = (element) => {
|
|
103
|
+
const rect = element.getBoundingClientRect();
|
|
104
|
+
spot.style.display = "block";
|
|
105
|
+
spot.style.left = `${Math.round(rect.left)}px`;
|
|
106
|
+
spot.style.top = `${Math.round(rect.top)}px`;
|
|
107
|
+
spot.style.width = `${Math.round(rect.width)}px`;
|
|
108
|
+
spot.style.height = `${Math.round(rect.height)}px`;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const show = (element) => { current = element; place(element); };
|
|
112
|
+
|
|
113
|
+
// ---- Disambiguation picker (shown when several editables overlap) ----
|
|
114
|
+
let picker = null;
|
|
115
|
+
let backdrop = null;
|
|
116
|
+
const removePicker = () => {
|
|
117
|
+
picker?.remove();
|
|
118
|
+
backdrop?.remove();
|
|
119
|
+
picker = null;
|
|
120
|
+
backdrop = null;
|
|
121
|
+
document.removeEventListener("keydown", onKey, true);
|
|
122
|
+
};
|
|
123
|
+
const onKey = (event) => { if (event.key === "Escape") { removePicker(); hideNow(); } };
|
|
124
|
+
|
|
125
|
+
const showPicker = (candidates, x, y, link) => {
|
|
126
|
+
removePicker();
|
|
127
|
+
// A transparent full-screen backdrop sits behind the picker and ABSORBS the
|
|
128
|
+
// next outside click — so dismissing the picker is a single action that never
|
|
129
|
+
// also edits or selects whatever was underneath.
|
|
130
|
+
backdrop = document.createElement("div");
|
|
131
|
+
backdrop.dataset.charlescmsUi = "true";
|
|
132
|
+
backdrop.className = "charlescms-picker-backdrop";
|
|
133
|
+
backdrop.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); removePicker(); hideNow(); });
|
|
134
|
+
document.body.append(backdrop);
|
|
135
|
+
picker = document.createElement("div");
|
|
136
|
+
picker.dataset.charlescmsUi = "true";
|
|
137
|
+
picker.className = "charlescms-picker";
|
|
138
|
+
picker.innerHTML = `<div class="charlescms-picker-head">Pick what to edit</div>`;
|
|
139
|
+
const addRow = (label, preview, onPick, onHover) => {
|
|
140
|
+
const row = document.createElement("button");
|
|
141
|
+
row.type = "button";
|
|
142
|
+
row.dataset.charlescmsUi = "true";
|
|
143
|
+
row.className = "charlescms-picker-item";
|
|
144
|
+
row.innerHTML = `<span class="charlescms-picker-label">${escapeHtml(label)}</span><span class="charlescms-picker-prev">${escapeHtml(preview)}</span>`;
|
|
145
|
+
row.addEventListener("pointerenter", () => onHover?.());
|
|
146
|
+
row.addEventListener("click", (event) => { event.preventDefault(); event.stopPropagation(); onPick(); });
|
|
147
|
+
picker.append(row);
|
|
148
|
+
};
|
|
149
|
+
for (const element of candidates) {
|
|
150
|
+
addRow(affordanceLabel(element), pickerPreview(element),
|
|
151
|
+
() => { removePicker(); hideNow(); element.charlescmsEdit.open(); },
|
|
152
|
+
() => place(element));
|
|
153
|
+
}
|
|
154
|
+
if (link) {
|
|
155
|
+
addRow("↗ Open link", link.getAttribute("href"),
|
|
156
|
+
// Open in a NEW tab so the current edit session and any unpublished
|
|
157
|
+
// changes are never lost by navigating the page away.
|
|
158
|
+
() => { removePicker(); hideNow(); window.open(link.href, "_blank"); },
|
|
159
|
+
() => hideNow());
|
|
160
|
+
}
|
|
161
|
+
document.body.append(picker);
|
|
162
|
+
positionPicker(picker, x, y);
|
|
163
|
+
document.addEventListener("keydown", onKey, true);
|
|
164
|
+
};
|
|
165
|
+
state.removeEditPicker = removePicker;
|
|
166
|
+
|
|
167
|
+
const onMove = (event) => {
|
|
168
|
+
if (event.pointerType === "touch") return;
|
|
169
|
+
if (event.target?.closest?.("[data-charlescms-ui]")) { clearTimeout(hideTimer); return; }
|
|
170
|
+
if (picker || document.querySelector(".charlescms-panel, .charlescms-rt-toolbar")) { hideNow(); clearIslandHighlight(); return; }
|
|
171
|
+
const next = resolveEditableAt(event.clientX, event.clientY);
|
|
172
|
+
if (!next || next.isContentEditable || next.classList.contains("charlescms-editing")) {
|
|
173
|
+
if (current) hideSoon();
|
|
174
|
+
// Editable wins; otherwise reveal the boundary of a hydrated island under
|
|
175
|
+
// the pointer so locked, in-code regions read as "interactive", not broken.
|
|
176
|
+
if (!next) highlightIslandAt(event.clientX, event.clientY);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
clearIslandHighlight();
|
|
180
|
+
clearTimeout(hideTimer);
|
|
181
|
+
if (current && current.isConnected && isVisible(current) && next !== current
|
|
182
|
+
&& next.contains(current) && withinPadded(current, event.clientX, event.clientY)) {
|
|
183
|
+
place(current);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
show(next);
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const onScroll = () => {
|
|
190
|
+
if (picker) { removePicker(); hideNow(); return; }
|
|
191
|
+
if (current && isVisible(current)) place(current);
|
|
192
|
+
else if (current) hideSoon();
|
|
193
|
+
repositionIslandHighlight();
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const onClick = (event) => {
|
|
197
|
+
if (!document.documentElement.classList.contains("charlescms-active")) return;
|
|
198
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
199
|
+
if (event.target.closest("[data-charlescms-ui]")) return;
|
|
200
|
+
|
|
201
|
+
const candidates = gatherCandidatesAt(event.clientX, event.clientY);
|
|
202
|
+
if (!candidates.length) {
|
|
203
|
+
const direct = event.target.closest('[data-charlescms-editable="true"]');
|
|
204
|
+
if (direct?.charlescmsEdit) candidates.push(direct);
|
|
205
|
+
else { showBoundaryHint(event.target, event.clientX, event.clientY); return; }
|
|
206
|
+
}
|
|
207
|
+
const link = event.target.closest("a[href]");
|
|
208
|
+
const navLink = link && !/^(?:tel:|mailto:|sms:)/i.test(link.getAttribute("href") || "") ? link : null;
|
|
209
|
+
|
|
210
|
+
// Several editables overlap here → let the user choose, never guess. Works
|
|
211
|
+
// the same for mouse and touch; the picker rows are big, finger-sized targets
|
|
212
|
+
// and suppress link navigation so a wrapped logo stays fully editable.
|
|
213
|
+
if (candidates.length > 1) {
|
|
214
|
+
event.preventDefault();
|
|
215
|
+
event.stopPropagation();
|
|
216
|
+
hideNow();
|
|
217
|
+
showPicker(candidates, event.clientX, event.clientY, navLink);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const element = candidates[0];
|
|
222
|
+
if (element.isContentEditable || element.classList.contains("charlescms-editing")) return;
|
|
223
|
+
|
|
224
|
+
// A single editable wrapped in a navigation link (the logo, a menu item)
|
|
225
|
+
// must never silently navigate on click — that's the "logo can't be edited"
|
|
226
|
+
// trap. Offer an explicit Edit vs Open choice instead, for mouse and touch.
|
|
227
|
+
if (navLink && navLink.contains(element)) {
|
|
228
|
+
event.preventDefault();
|
|
229
|
+
event.stopPropagation();
|
|
230
|
+
hideNow();
|
|
231
|
+
showPicker([element], event.clientX, event.clientY, navLink);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// A single editable: a tap or click edits it directly. (Several overlapping
|
|
236
|
+
// editables took the picker branch above; the picker's big rows cover the
|
|
237
|
+
// fat-finger case, so no separate touch confirm step is needed.)
|
|
238
|
+
event.preventDefault();
|
|
239
|
+
event.stopPropagation();
|
|
240
|
+
hideNow();
|
|
241
|
+
element.charlescmsEdit.open();
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
document.addEventListener("pointermove", onMove, { passive: true });
|
|
245
|
+
document.addEventListener("scroll", onScroll, { passive: true, capture: true });
|
|
246
|
+
document.addEventListener("click", onClick, true);
|
|
247
|
+
|
|
248
|
+
state.affordanceCleanup = () => {
|
|
249
|
+
document.removeEventListener("pointermove", onMove);
|
|
250
|
+
document.removeEventListener("scroll", onScroll, { capture: true });
|
|
251
|
+
document.removeEventListener("click", onClick, true);
|
|
252
|
+
removePicker();
|
|
253
|
+
clearIslandHighlight();
|
|
254
|
+
clearBoundaryHint();
|
|
255
|
+
clearTimeout(hideTimer);
|
|
256
|
+
state.hideEditSpot = null;
|
|
257
|
+
state.removeEditPicker = null;
|
|
258
|
+
spot.remove();
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return { installEditAffordance };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// A short, human preview of an editable for a picker row, so the choice is obvious
|
|
266
|
+
// without reading the page ("Edit text · Zum Charles", "Replace image · hero.jpg").
|
|
267
|
+
function pickerPreview(element) {
|
|
268
|
+
if (element.tagName === "IMG" || element.querySelector?.("img")) {
|
|
269
|
+
const img = element.tagName === "IMG" ? element : element.querySelector("img");
|
|
270
|
+
return img.getAttribute("alt") || (img.getAttribute("src") || "").split(/[?#]/)[0].split("/").pop() || "image";
|
|
271
|
+
}
|
|
272
|
+
// A link row describes WHERE it points, so "Edit link" never reads as a duplicate
|
|
273
|
+
// of the "Edit text" row for the very same words ("Edit link · /about" vs "Edit
|
|
274
|
+
// text · About us").
|
|
275
|
+
if (element.tagName === "A") {
|
|
276
|
+
const href = (element.getAttribute("href") || "").trim();
|
|
277
|
+
if (href) return href;
|
|
278
|
+
}
|
|
279
|
+
const text = (element.textContent || "").replace(/\s+/g, " ").trim();
|
|
280
|
+
return text.length > 44 ? `${text.slice(0, 44)}…` : (text || element.tagName.toLowerCase());
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Place the picker next to the click, clamped to stay fully on-screen.
|
|
284
|
+
function positionPicker(menu, x, y) {
|
|
285
|
+
const r = menu.getBoundingClientRect();
|
|
286
|
+
let left = x + 10;
|
|
287
|
+
let top = y + 10;
|
|
288
|
+
if (left + r.width > window.innerWidth - 8) left = Math.max(8, x - r.width - 10);
|
|
289
|
+
if (top + r.height > window.innerHeight - 8) top = Math.max(8, y - r.height - 10);
|
|
290
|
+
menu.style.left = `${Math.max(8, left)}px`;
|
|
291
|
+
menu.style.top = `${Math.max(8, top)}px`;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Explains the editing boundary when a click lands on content the page generates
|
|
295
|
+
// (so there is no source to edit). Fires only on text/media-like elements the user
|
|
296
|
+
// could reasonably expect to edit — never on chrome, links, or already-editable
|
|
297
|
+
// content — so it informs without nagging.
|
|
298
|
+
let boundaryHintTimer;
|
|
299
|
+
function showBoundaryHint(clickTarget, pointerX, pointerY) {
|
|
300
|
+
const element = boundaryHintTarget(clickTarget);
|
|
301
|
+
if (!element) return;
|
|
302
|
+
clearBoundaryHint();
|
|
303
|
+
const isMedia = element.tagName === "IMG" || element.tagName === "PICTURE" || element.tagName === "SVG"
|
|
304
|
+
|| Boolean(element.querySelector?.("img,video,iframe,svg"));
|
|
305
|
+
// An Astro framework island (a hydrated React/Vue/Svelte/… component) renders to
|
|
306
|
+
// a <astro-island> custom element. Its text lives in code, not in any editable
|
|
307
|
+
// source the CMS owns — so say so plainly, and outline the WHOLE island so the
|
|
308
|
+
// user sees exactly which block is off-limits, instead of guessing per word.
|
|
309
|
+
const island = element.closest("astro-island");
|
|
310
|
+
const content = boundaryHintContent({ isMedia, isInteractive: Boolean(island) });
|
|
311
|
+
|
|
312
|
+
// The outline + badge are owned by the hover layer (so they persist while the
|
|
313
|
+
// pointer is over the island); a click just adds the plain-language "why".
|
|
314
|
+
if (island) highlightIsland(island);
|
|
315
|
+
|
|
316
|
+
const rect = element.getBoundingClientRect();
|
|
317
|
+
const hint = document.createElement("div");
|
|
318
|
+
hint.dataset.charlescmsUi = "true";
|
|
319
|
+
hint.className = `charlescms-boundary-hint charlescms-boundary-hint--${content.variant}`;
|
|
320
|
+
// For an island the badge already carries the ⚡ title, so the callout is just
|
|
321
|
+
// the plain-language "why"; media/data keep their icon + line.
|
|
322
|
+
hint.innerHTML = island
|
|
323
|
+
? `<span>${escapeHtml(content.text)}</span>`
|
|
324
|
+
: `<span class="charlescms-boundary-hint-icon" aria-hidden="true">${content.icon}</span>`
|
|
325
|
+
+ `<span class="charlescms-boundary-hint-body">`
|
|
326
|
+
+ (content.title ? `<span class="charlescms-boundary-hint-title">${escapeHtml(content.title)}</span>` : "")
|
|
327
|
+
+ `<span>${escapeHtml(content.text)}</span></span>`;
|
|
328
|
+
// Anchor the callout to the click point (offset so it never sits under the
|
|
329
|
+
// cursor or hides the very content it describes), clamped on-screen.
|
|
330
|
+
const anchorX = Number.isFinite(pointerX) ? pointerX : rect.left;
|
|
331
|
+
const anchorY = Number.isFinite(pointerY) ? pointerY : rect.bottom;
|
|
332
|
+
document.body.append(hint);
|
|
333
|
+
const hr = hint.getBoundingClientRect();
|
|
334
|
+
let left = anchorX + 14;
|
|
335
|
+
let top = anchorY + 14;
|
|
336
|
+
if (left + hr.width > window.innerWidth - 10) left = Math.max(10, anchorX - hr.width - 14);
|
|
337
|
+
if (top + hr.height > window.innerHeight - 10) top = Math.max(10, anchorY - hr.height - 14);
|
|
338
|
+
hint.style.left = `${Math.round(left)}px`;
|
|
339
|
+
hint.style.top = `${Math.round(top)}px`;
|
|
340
|
+
requestAnimationFrame(() => hint.classList.add("charlescms-boundary-hint-in"));
|
|
341
|
+
clearTimeout(boundaryHintTimer);
|
|
342
|
+
boundaryHintTimer = setTimeout(clearBoundaryHint, 3200);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function clearBoundaryHint() {
|
|
346
|
+
clearTimeout(boundaryHintTimer);
|
|
347
|
+
for (const node of document.querySelectorAll(".charlescms-boundary-hint")) node.remove();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---- Hydrated-island highlight (hover + click) ------------------------------
|
|
351
|
+
// One amber outline + "Interactive component" badge marks the boundary of a
|
|
352
|
+
// hydrated <astro-island>. It is purely visual (never writes), so it stays safe
|
|
353
|
+
// on every page; it simply makes the edit boundary legible the way a design tool
|
|
354
|
+
// marks a locked symbol. Module-level state: one editor instance per page.
|
|
355
|
+
let islandHighlight = { island: null, veil: null, badge: null };
|
|
356
|
+
|
|
357
|
+
function highlightIslandAt(x, y) {
|
|
358
|
+
const top = document.elementFromPoint(x, y);
|
|
359
|
+
if (!top || top.closest("[data-charlescms-ui]") || top.closest('[data-charlescms-editable="true"]')) {
|
|
360
|
+
clearIslandHighlight();
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
const island = top.closest("astro-island");
|
|
364
|
+
if (island) highlightIsland(island);
|
|
365
|
+
else clearIslandHighlight();
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function highlightIsland(island) {
|
|
369
|
+
if (islandHighlight.island === island && islandHighlight.veil?.isConnected) {
|
|
370
|
+
repositionIslandHighlight();
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
clearIslandHighlight();
|
|
374
|
+
const veil = document.createElement("div");
|
|
375
|
+
veil.dataset.charlescmsUi = "true";
|
|
376
|
+
veil.className = "charlescms-island-veil";
|
|
377
|
+
const badge = document.createElement("div");
|
|
378
|
+
badge.dataset.charlescmsUi = "true";
|
|
379
|
+
badge.className = "charlescms-island-badge";
|
|
380
|
+
badge.textContent = "⚡ Interactive component";
|
|
381
|
+
document.body.append(veil, badge);
|
|
382
|
+
islandHighlight = { island, veil, badge };
|
|
383
|
+
repositionIslandHighlight();
|
|
384
|
+
requestAnimationFrame(() => {
|
|
385
|
+
veil.classList.add("charlescms-island-veil-in");
|
|
386
|
+
badge.classList.add("charlescms-island-veil-in");
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function repositionIslandHighlight() {
|
|
391
|
+
const { island, veil, badge } = islandHighlight;
|
|
392
|
+
if (!island?.isConnected || !veil) return;
|
|
393
|
+
const r = island.getBoundingClientRect();
|
|
394
|
+
veil.style.left = `${Math.round(r.left)}px`;
|
|
395
|
+
veil.style.top = `${Math.round(r.top)}px`;
|
|
396
|
+
veil.style.width = `${Math.round(r.width)}px`;
|
|
397
|
+
veil.style.height = `${Math.round(r.height)}px`;
|
|
398
|
+
badge.style.left = `${Math.max(8, Math.round(r.left))}px`;
|
|
399
|
+
badge.style.top = `${Math.max(8, Math.round(r.top - 10))}px`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function clearIslandHighlight() {
|
|
403
|
+
islandHighlight.veil?.remove();
|
|
404
|
+
islandHighlight.badge?.remove();
|
|
405
|
+
islandHighlight = { island: null, veil: null, badge: null };
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// The boundary message, kept pure so the copy and classification are unit-tested
|
|
409
|
+
// (the DOM detection above stays thin glue). Interactive islands get the most
|
|
410
|
+
// specific, actionable line; media and generated values fall back in that order.
|
|
411
|
+
export function boundaryHintContent({ isMedia, isInteractive }) {
|
|
412
|
+
if (isInteractive) {
|
|
413
|
+
return {
|
|
414
|
+
variant: "interactive",
|
|
415
|
+
icon: "⚡",
|
|
416
|
+
title: "Interactive component",
|
|
417
|
+
text: "This area is built in code, so a developer edits it — it can’t be changed here."
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
if (isMedia) {
|
|
421
|
+
return {
|
|
422
|
+
variant: "media",
|
|
423
|
+
icon: "🔒",
|
|
424
|
+
title: "Managed in code",
|
|
425
|
+
text: "This image comes from the page’s code or data, so it can’t be replaced here."
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
return {
|
|
429
|
+
variant: "data",
|
|
430
|
+
icon: "🔒",
|
|
431
|
+
title: "Auto-filled value",
|
|
432
|
+
text: "This is generated automatically (like a date or a data field), so it can’t be edited here."
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function boundaryHintTarget(node) {
|
|
437
|
+
if (!node?.closest || node.closest("[data-charlescms-ui]")) return null;
|
|
438
|
+
if (node.closest("a[href]")) return null; // links navigate, so don't second-guess them
|
|
439
|
+
// Cover the content elements a person could plausibly try to edit — including
|
|
440
|
+
// generated values (a <time> date, a <span> from data) — so clicking ANY of
|
|
441
|
+
// them explains itself instead of being a silent dead-end on any site.
|
|
442
|
+
const element = node.closest(
|
|
443
|
+
"p,h1,h2,h3,h4,h5,h6,li,blockquote,figcaption,td,th,dd,dt,time,span,em,strong,small,b,i,mark,cite,abbr,label,address,button,img,picture,svg,figure"
|
|
444
|
+
);
|
|
445
|
+
if (!element || element.closest('[data-charlescms-editable="true"]')) return null;
|
|
446
|
+
const isMedia = element.tagName === "IMG" || element.tagName === "PICTURE" || element.tagName === "SVG"
|
|
447
|
+
|| Boolean(element.querySelector?.("img,video,iframe,svg"));
|
|
448
|
+
if (!isMedia && (element.textContent || "").trim().length < 2) return null;
|
|
449
|
+
return element;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function affordanceLabel(element) {
|
|
453
|
+
// A staged (not yet published) section edits as one block in the builder.
|
|
454
|
+
if (element.dataset?.charlescmsPreviewBlock) return "✎ Edit new section";
|
|
455
|
+
if (element.tagName === "IMG" || element.querySelector?.("img")) return "⤢ Replace image";
|
|
456
|
+
if (element.tagName === "A") return "✎ Edit link";
|
|
457
|
+
if (element.matches?.("ul,ol") || element.querySelector?.("li")) return "✎ Edit list";
|
|
458
|
+
return "✎ Edit text";
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function escapeHtml(value) {
|
|
462
|
+
return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]);
|
|
463
|
+
}
|