@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,243 @@
|
|
|
1
|
+
// All CSS for the editor's own UI (toolbar, panels, highlight ring, boundary hints).
|
|
2
|
+
// It lives in one exported string so the runtime can inject it with a single
|
|
3
|
+
// <style> tag, and so the markup logic in client.js stays readable. Class names
|
|
4
|
+
// are all prefixed charlescms- and z-indexes sit at the top of the stacking
|
|
5
|
+
// order, so these rules never collide with the site's own styles.
|
|
6
|
+
export const EDITOR_CSS = `
|
|
7
|
+
|
|
8
|
+
/* ----- Design tokens ----- */
|
|
9
|
+
:root { --charlescms-ink: #172026; --charlescms-muted: #5b6970; --charlescms-line: #d8ded8; --charlescms-accent: #176b5c; --charlescms-gold: #f2c14e; --charlescms-danger: #a3342a; --charlescms-edit: #2563eb; --charlescms-edit-soft: rgba(37,99,235,.14); }
|
|
10
|
+
|
|
11
|
+
/* ----- Page adjustments while the editor is active ----- */
|
|
12
|
+
html.charlescms-active { scroll-behavior: auto !important; scroll-padding-bottom: var(--charlescms-toolbar-reserve, 120px); scroll-padding-top: 80px; }
|
|
13
|
+
html.charlescms-active body { padding-bottom: calc(var(--charlescms-page-padding-bottom, 0px) + var(--charlescms-toolbar-reserve, 120px)) !important; }
|
|
14
|
+
/* One uniform edit hint for EVERY editable element, shown only on hover so
|
|
15
|
+
the page looks like its original design — never a persistent border that
|
|
16
|
+
competes with the site's own. The "Show editable" toggle reveals them all. */
|
|
17
|
+
|
|
18
|
+
/* ----- Editable elements + "Show editable" rings ----- */
|
|
19
|
+
[data-charlescms-editable="true"] { border-radius: 3px; cursor: pointer !important; }
|
|
20
|
+
/* "Show editable" rings EVERY editable element identically. It uses OUTLINE
|
|
21
|
+
with a NEGATIVE offset (drawn just inside the element): an inset box-shadow
|
|
22
|
+
is INVISIBLE on replaced elements like <img>, so images would show no ring;
|
|
23
|
+
a negative-offset outline renders on images and text alike and, being
|
|
24
|
+
inside the element's own box, is never clipped by an ancestor's
|
|
25
|
+
overflow:hidden. The hover highlight itself is the JS overlay
|
|
26
|
+
(.charlescms-spot) — a real DIV, so its ring shows on covered images too. */
|
|
27
|
+
/* Passive "Show editable": a soft fill + a 1px hairline ring drawn INSIDE the
|
|
28
|
+
element's own box (inset box-shadow). Because it lives inside the box it
|
|
29
|
+
never clips the text and never overlaps a neighbour — unlike an outside
|
|
30
|
+
outline, which did both. */
|
|
31
|
+
/* Dual-tone ring: the blue line plus a faint white inner halo, so the mark
|
|
32
|
+
stays readable on ANY customer design — dark heroes and footers included.
|
|
33
|
+
Ring ONLY — never a background fill: a fill would REPLACE the element's
|
|
34
|
+
own background (a white button on a dark hero turned unreadable). */
|
|
35
|
+
html.charlescms-show-all [data-charlescms-editable="true"] { box-shadow: inset 0 0 0 1px rgba(37,99,235,.45), inset 0 0 0 2px rgba(255,255,255,.35) !important; }
|
|
36
|
+
/* Replaced elements (images, video, embeds) paint OVER any fill or inset
|
|
37
|
+
shadow, so they use a thin inset outline instead (also never clipped by
|
|
38
|
+
an overflow:hidden wrapper). */
|
|
39
|
+
/* Replaced elements paint over inset shadows, so: blue outline just inside
|
|
40
|
+
the box + a 1px white OUTER halo (outer shadows do render on them). */
|
|
41
|
+
html.charlescms-show-all :is(img, video, iframe, embed, object)[data-charlescms-editable="true"] { outline: 2px solid rgba(37,99,235,.6) !important; outline-offset: -2px !important; box-shadow: 0 0 0 1px rgba(255,255,255,.45) !important; }
|
|
42
|
+
/* SVG text (e.g. a logo monogram) can't show a CSS fill or box-shadow, so it
|
|
43
|
+
gets a thin outline instead — which SVG elements do render. */
|
|
44
|
+
html.charlescms-show-all text[data-charlescms-editable="true"] { box-shadow: none !important; outline: 1.5px solid rgba(37,99,235,.7) !important; outline-offset: 2px !important; }
|
|
45
|
+
.charlescms-toolbar [data-charlescms-highlight].charlescms-toggle-on { background: var(--charlescms-edit); border-color: var(--charlescms-edit); color: #fff; }
|
|
46
|
+
|
|
47
|
+
/* The one unified highlight: a ring at the element's exact bounds (pointer
|
|
48
|
+
through to the page) plus a clickable label tab that names the action. */
|
|
49
|
+
/* The one unified highlight: a ring at the element's exact bounds (clicks
|
|
50
|
+
pass through to the page) plus a clickable label tab that names the action.
|
|
51
|
+
The tab sits just ABOVE the element so it never covers the content. */
|
|
52
|
+
|
|
53
|
+
/* ----- Hover highlight (ring + label tab) ----- */
|
|
54
|
+
/* A quiet hover ring only (pointer-events:none) — it never covers content or
|
|
55
|
+
blocks a click; the element itself is clicked to edit. */
|
|
56
|
+
.charlescms-spot { position: fixed; z-index: 2147483640; pointer-events: none; border-radius: 4px; box-shadow: inset 0 0 0 2px var(--charlescms-edit), 0 0 0 1px rgba(37,99,235,.22); background: rgba(37,99,235,.05); }
|
|
57
|
+
|
|
58
|
+
.charlescms-boundary-hint { position: fixed; z-index: 2147483646; max-width: 264px; display: flex; gap: 9px; align-items: flex-start; padding: 11px 13px; transform: translateY(4px) scale(.98); transform-origin: top left; background: rgba(20,26,32,.96); color: #dfe5e4; border: 1px solid rgba(255,255,255,.09); border-radius: 12px; font: 500 12px/1.45 system-ui, sans-serif; box-shadow: 0 14px 38px rgba(6,10,14,.42); backdrop-filter: blur(16px); pointer-events: none; opacity: 0; transition: opacity .16s ease, transform .16s cubic-bezier(.2,.8,.2,1); }
|
|
59
|
+
.charlescms-boundary-hint-in { opacity: 1; transform: translateY(0) scale(1); }
|
|
60
|
+
.charlescms-boundary-hint-icon { flex: none; font-size: 14px; line-height: 1.4; opacity: .85; }
|
|
61
|
+
.charlescms-boundary-hint-body { display: flex; flex-direction: column; gap: 2px; }
|
|
62
|
+
.charlescms-boundary-hint-title { font-weight: 650; color: #fff; letter-spacing: .01em; }
|
|
63
|
+
.charlescms-boundary-hint--interactive { border-color: rgba(255,196,84,.38); }
|
|
64
|
+
.charlescms-boundary-hint--interactive .charlescms-boundary-hint-icon { color: #ffc454; opacity: 1; }
|
|
65
|
+
.charlescms-boundary-hint--interactive .charlescms-boundary-hint-title { color: #ffd98a; }
|
|
66
|
+
|
|
67
|
+
/* Outlines the whole hydrated island so the user sees the off-limits boundary. */
|
|
68
|
+
.charlescms-island-veil { position: fixed; z-index: 2147483645; pointer-events: none; border-radius: 12px; border: 1.5px dashed rgba(255,196,84,.5); background: rgba(255,196,84,.045); opacity: 0; transition: opacity .18s ease; }
|
|
69
|
+
.charlescms-island-veil-in { opacity: 1; }
|
|
70
|
+
.charlescms-island-badge { position: fixed; z-index: 2147483646; pointer-events: none; padding: 3px 9px; border-radius: 999px; background: #ffc454; color: #1a1205; font: 700 11px/1.4 system-ui, sans-serif; letter-spacing: .02em; box-shadow: 0 5px 16px rgba(8,12,18,.4); white-space: nowrap; opacity: 0; transform: translateY(3px); transition: opacity .18s ease, transform .18s cubic-bezier(.2,.8,.2,1); }
|
|
71
|
+
.charlescms-island-badge.charlescms-island-veil-in { opacity: 1; transform: translateY(0); }
|
|
72
|
+
|
|
73
|
+
/* ----- Disambiguation picker (overlapping / tightly-packed editables) ----- */
|
|
74
|
+
/* Appears AT the click point so it's always reachable; each row is a big target
|
|
75
|
+
that names the action and previews the content, and hovering a row highlights
|
|
76
|
+
the matching element. The user chooses — the editor never guesses wrong. */
|
|
77
|
+
.charlescms-picker-backdrop { position: fixed; inset: 0; z-index: 2147483644; background: transparent; }
|
|
78
|
+
.charlescms-picker { position: fixed; z-index: 2147483646; box-sizing: border-box; width: min(330px, calc(100vw - 16px)); padding: 6px; background: rgba(251,252,250,.99); border: 1px solid rgba(103,119,112,.28); border-radius: 12px; box-shadow: 0 22px 60px rgba(19,29,34,.28), 0 2px 8px rgba(19,29,34,.1); backdrop-filter: blur(16px); font: 13px system-ui, sans-serif; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
|
|
79
|
+
.charlescms-picker-head { padding: 7px 10px 5px; color: #6b7a76; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; }
|
|
80
|
+
.charlescms-picker-item { box-sizing: border-box; display: flex; align-items: center; gap: 9px; width: 100%; min-width: 0; min-height: 44px; padding: 9px 11px; border: 0; border-radius: 8px; background: transparent; color: var(--charlescms-ink); font: inherit; text-align: left; cursor: pointer; touch-action: manipulation; }
|
|
81
|
+
.charlescms-picker-item:hover, .charlescms-picker-item:focus-visible { background: var(--charlescms-edit-soft); outline: none; }
|
|
82
|
+
.charlescms-picker-label { flex: 0 0 auto; font-weight: 750; white-space: nowrap; }
|
|
83
|
+
.charlescms-picker-prev { flex: 1 1 auto; min-width: 0; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--charlescms-muted); }
|
|
84
|
+
|
|
85
|
+
/* ----- "Add section" controls ----- */
|
|
86
|
+
.charlescms-section-gap { position: fixed; z-index: 2147483645; display: flex; align-items: center; justify-content: center; gap: 7px; height: 28px; border: 0; border-top: 1px dashed rgba(23,107,92,.55); background: transparent; color: var(--charlescms-accent); transform: translateY(-50%); font: 800 11px/1 system-ui, sans-serif; cursor: pointer; opacity: .66; transition: opacity .15s ease, color .15s ease, border-color .15s ease; }
|
|
87
|
+
.charlescms-section-gap span { display: inline-flex; align-items: center; background: rgba(248,250,247,.96); padding: 4px 7px; border: 1px solid rgba(23,107,92,.24); border-radius: 999px; box-shadow: 0 4px 14px rgba(23,32,38,.08); }
|
|
88
|
+
.charlescms-section-gap span:first-child { width: 20px; height: 20px; justify-content: center; padding: 0; background: var(--charlescms-accent); color: white; border-color: var(--charlescms-accent); font-size: 16px; }
|
|
89
|
+
.charlescms-section-gap:hover, .charlescms-section-gap:focus-visible { opacity: 1; color: #0d5549; border-color: var(--charlescms-gold); outline: none; }
|
|
90
|
+
.charlescms-section-gap[hidden] { display: none !important; }
|
|
91
|
+
.charlescms-editing { outline: 2px solid var(--charlescms-edit) !important; outline-offset: 3px !important; cursor: text !important; background: var(--charlescms-edit-soft) !important; }
|
|
92
|
+
/* The Tiptap/ProseMirror editable mounted inside the element edits in place,
|
|
93
|
+
inheriting the element's own font/size/colour so the page stays the preview. */
|
|
94
|
+
.charlescms-pm { outline: none !important; white-space: pre-wrap; }
|
|
95
|
+
.charlescms-pm:focus, .charlescms-pm:focus-visible { outline: none !important; }
|
|
96
|
+
.charlescms-pm p { margin: 0; }
|
|
97
|
+
.charlescms-editing-plain { -webkit-text-fill-color: initial !important; background-image: none !important; -webkit-background-clip: border-box !important; background-clip: border-box !important; color: #f6f6fb !important; background-color: rgba(12,12,18,.92) !important; caret-color: #f6f6fb !important; border-radius: 8px !important; padding: 0 .15em !important; }
|
|
98
|
+
|
|
99
|
+
/* ----- Inline rich-text toolbar ----- */
|
|
100
|
+
.charlescms-rt-toolbar { position: fixed; z-index: 2147483647; display: flex; flex-direction: column; gap: 5px; align-items: stretch; padding: 6px; background: rgba(23,32,38,.96); color: white; border: 1px solid rgba(255,255,255,.16); border-radius: 8px; box-shadow: 0 18px 50px rgba(8,14,18,.34); font: 13px system-ui, sans-serif; backdrop-filter: blur(14px); }
|
|
101
|
+
.charlescms-rt-row { display: flex; gap: 5px; align-items: center; }
|
|
102
|
+
.charlescms-rt-hint { max-width: 320px; padding: 6px 8px 2px; color: var(--charlescms-gold); font-size: 12px; line-height: 1.35; }
|
|
103
|
+
.charlescms-rt-hint[hidden] { display: none; }
|
|
104
|
+
.charlescms-rt-toolbar button { min-width: 32px; height: 32px; padding: 0 9px; display: grid; place-items: center; border: 0; border-radius: 7px; background: transparent; color: white; font: inherit; cursor: pointer; }
|
|
105
|
+
.charlescms-rt-toolbar button:hover { background: rgba(255,255,255,.14); }
|
|
106
|
+
.charlescms-rt-toolbar [data-rt-save] { background: var(--charlescms-gold); color: var(--charlescms-ink); font-weight: 900; }
|
|
107
|
+
.charlescms-rt-sep { width: 1px; height: 20px; background: rgba(255,255,255,.2); margin: 0 4px; }
|
|
108
|
+
.charlescms-rt-error { position: absolute; top: calc(100% + 6px); left: 0; max-width: 320px; color: #fff; background: var(--charlescms-danger); border-radius: 7px; padding: 8px 10px; line-height: 1.35; font-size: 12px; }
|
|
109
|
+
/* Shrink-to-content (no forced width) so the bar is always exactly as wide as
|
|
110
|
+
what's in it — no dead space. Three tight groups: brand dot + name, status,
|
|
111
|
+
and a divided action cluster. Reads as one considered object in both the
|
|
112
|
+
clean and the "changes staged" state. */
|
|
113
|
+
|
|
114
|
+
/* ----- Main toolbar ----- */
|
|
115
|
+
.charlescms-toolbar { position: fixed; right: 16px; bottom: 16px; z-index: 2147483647; display: flex; gap: 0 12px; align-items: center; flex-wrap: wrap; max-width: min(920px, calc(100vw - 32px)); padding: 7px 10px 7px 15px; background: rgba(18,25,30,.92); color: #fff; border: 1px solid rgba(255,255,255,.09); border-radius: 14px; font: 13px system-ui, sans-serif; box-shadow: 0 16px 44px rgba(8,14,18,.3); backdrop-filter: blur(20px); }
|
|
116
|
+
.charlescms-brand { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 8px; }
|
|
117
|
+
.charlescms-brand::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--charlescms-accent); box-shadow: 0 0 0 3px rgba(23,107,92,.22); }
|
|
118
|
+
.charlescms-brand strong { font-size: 12.5px; font-weight: 650; letter-spacing: .01em; opacity: .96; }
|
|
119
|
+
.charlescms-info { flex: 0 1 auto; min-width: 0; color: #8a9796; font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
120
|
+
.charlescms-info:empty { display: none; }
|
|
121
|
+
.charlescms-info-pending { color: var(--charlescms-gold); font-weight: 700; }
|
|
122
|
+
.charlescms-toolbar-actions { display: flex; gap: 6px; align-items: center; flex: 0 0 auto; flex-wrap: wrap; padding-left: 13px; border-left: 1px solid rgba(255,255,255,.1); }
|
|
123
|
+
.charlescms-toolbar button { color: #dfe6e5; text-decoration: none; border: 0; padding: 7px 11px; background: transparent; border-radius: 9px; font: inherit; font-size: 12.5px; font-weight: 550; cursor: pointer; white-space: nowrap; transition: background .12s ease, color .12s ease; }
|
|
124
|
+
.charlescms-toolbar button:hover { background: rgba(255,255,255,.08); color: #fff; }
|
|
125
|
+
.charlescms-toolbar [data-charlescms-publish] { background: var(--charlescms-gold); border-color: var(--charlescms-gold); color: var(--charlescms-ink); font-weight: 700; padding: 7px 14px; }
|
|
126
|
+
.charlescms-toolbar [data-charlescms-publish]:hover { background: #e7b43e; color: var(--charlescms-ink); }
|
|
127
|
+
.charlescms-toolbar [data-charlescms-discard] { color: #ffd7d1; border-color: rgba(255, 160, 145, .28); }
|
|
128
|
+
.charlescms-toolbar button:disabled { cursor: not-allowed; opacity: .55; }
|
|
129
|
+
|
|
130
|
+
/* ----- Panels (editor, content, versions) ----- */
|
|
131
|
+
/* ONE panel family: every panel (field editor, content browser, versions)
|
|
132
|
+
shares the same surface, width, radius and shadow, so moving between the
|
|
133
|
+
content list and an editor never "jumps" — only the inside changes. */
|
|
134
|
+
/* The panel never grows past the viewport (max-height) and is the SINGLE
|
|
135
|
+
scroll container; its header and action row stay pinned (sticky, below) so
|
|
136
|
+
Save is always one tap away no matter how long the form is. */
|
|
137
|
+
.charlescms-panel { position: fixed; right: 16px; top: 16px; z-index: 2147483647; width: min(480px, calc(100vw - 32px)); max-height: calc(100vh - 32px); display: grid; gap: 15px; overflow: auto; overscroll-behavior: contain; padding: 22px; background: rgba(251,252,250,.985); color: var(--charlescms-ink); border: 1px solid rgba(103,119,112,.24); border-radius: 16px; box-shadow: 0 32px 90px rgba(19,29,34,.2), 0 2px 8px rgba(19,29,34,.08); font: 14px system-ui, sans-serif; backdrop-filter: blur(16px); }
|
|
138
|
+
/* Pinned header: stays visible (with title + close) while the form scrolls.
|
|
139
|
+
The negative top/side margins + restored padding let it span the panel's
|
|
140
|
+
full width and cover the panel's own top padding as content scrolls under. */
|
|
141
|
+
.charlescms-panel-header { position: sticky; top: -22px; z-index: 3; margin: -22px -22px 0; padding: 22px 22px 13px; background: rgba(251,252,250,.97); backdrop-filter: blur(8px); display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; border-bottom: 1px solid #e2e7e2; }
|
|
142
|
+
.charlescms-back { background: none; border: 0; margin: 0 0 5px; padding: 0; color: #6b7a76; font: 650 12px system-ui, sans-serif; cursor: pointer; display: inline-flex; align-items: center; }
|
|
143
|
+
.charlescms-back:hover { color: var(--charlescms-accent); }
|
|
144
|
+
.charlescms-kicker { color: #6b7a76; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .09em; }
|
|
145
|
+
.charlescms-title { font-weight: 600; font-size: 18px; line-height: 1.2; letter-spacing: -.01em; }
|
|
146
|
+
.charlescms-icon-button { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid #cfd8d2; background: #f8faf7; color: var(--charlescms-ink); border-radius: 7px; font: 22px/1 system-ui, sans-serif; cursor: pointer; }
|
|
147
|
+
.charlescms-icon-button:hover { background: #eef3ef; }
|
|
148
|
+
.charlescms-panel-copy { margin: -3px 0 0; color: var(--charlescms-muted); line-height: 1.5; }
|
|
149
|
+
.charlescms-panel-subtitle { max-width: 34ch; margin: 5px 0 0; color: #71807b; font-size: 12.5px; line-height: 1.45; }
|
|
150
|
+
.charlescms-visually-hidden { position: absolute !important; width: 1px !important; height: 1px !important; padding: 0 !important; margin: -1px !important; overflow: hidden !important; clip: rect(0,0,0,0) !important; white-space: nowrap !important; border: 0 !important; }
|
|
151
|
+
.charlescms-template-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
|
|
152
|
+
.charlescms-template-card { display: grid; gap: 7px; min-width: 0; padding: 0 0 11px; overflow: hidden; text-align: left; border: 1px solid #d8ded8; border-radius: 10px; background: white; color: var(--charlescms-ink); font: inherit; cursor: pointer; box-shadow: 0 8px 22px rgba(23,32,38,.05); transition: border-color .15s ease, transform .15s ease, box-shadow .15s ease; }
|
|
153
|
+
.charlescms-template-card:hover { border-color: var(--charlescms-accent); transform: translateY(-2px); box-shadow: 0 12px 28px rgba(23,32,38,.1); }
|
|
154
|
+
.charlescms-template-card > strong, .charlescms-template-card > span:last-child { margin-inline: 11px; }
|
|
155
|
+
.charlescms-template-card > span:last-child { color: var(--charlescms-muted); font-size: 11px; }
|
|
156
|
+
.charlescms-template-preview { display: grid; place-items: center; min-height: 112px; max-height: 112px; overflow: hidden; padding: 12px; background: linear-gradient(145deg, #edf4ef, #f9f5e8); color: var(--charlescms-ink); pointer-events: none; }
|
|
157
|
+
.charlescms-template-preview > * { max-width: 100%; margin: 3px 0; transform: scale(.72); transform-origin: center; }
|
|
158
|
+
[data-charlescms-preview-block] { outline: 2px solid var(--charlescms-gold) !important; outline-offset: 5px; animation: charlescms-section-in .24s ease-out; }
|
|
159
|
+
@keyframes charlescms-section-in { from { opacity: .25; transform: translateY(8px); } to { opacity: 1; transform: none; } }
|
|
160
|
+
.charlescms-error { color: var(--charlescms-danger); background: #fff1ee; border: 1px solid #f0b6ad; border-radius: 7px; padding: 10px 11px; line-height: 1.4; }
|
|
161
|
+
.charlescms-panel label { display: grid; gap: 7px; font-weight: 800; color: #26333d; }
|
|
162
|
+
.charlescms-panel textarea { min-height: 150px; resize: vertical; }
|
|
163
|
+
.charlescms-panel input, .charlescms-panel textarea { width: 100%; box-sizing: border-box; border: 1px solid #d6ded9; background: #fff; color: var(--charlescms-ink); border-radius: 11px; padding: 12px 13px; font: inherit; outline: none; box-shadow: 0 1px 2px rgba(22,34,39,.03); }
|
|
164
|
+
.charlescms-panel input:focus, .charlescms-panel textarea:focus { border-color: var(--charlescms-accent); background: white; box-shadow: 0 0 0 4px rgba(23,107,92,.13); }
|
|
165
|
+
.charlescms-media-tools { display: grid; gap: 10px; padding: 12px; border: 1px solid #e1e6df; border-radius: 7px; background: #f8faf7; }
|
|
166
|
+
.charlescms-media-tools div { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
|
|
167
|
+
.charlescms-media-tools button { border: 1px solid #cfd8d2; background: white; color: var(--charlescms-ink); border-radius: 7px; padding: 9px 11px; font: inherit; font-weight: 800; cursor: pointer; }
|
|
168
|
+
.charlescms-media-tools [data-remove] { border-color: #edbbb2; color: var(--charlescms-danger); }
|
|
169
|
+
/* Pinned action row: Save/Cancel stay stuck to the bottom of the panel and are
|
|
170
|
+
always visible — never scrolled off, however long the form. The negative
|
|
171
|
+
margins bleed it to the panel edges; bottom:-22px cancels the panel's bottom
|
|
172
|
+
padding so it sits flush; the solid backdrop keeps it legible over content
|
|
173
|
+
scrolling beneath. safe-area padding clears the iOS home indicator. */
|
|
174
|
+
.charlescms-actions { position: sticky; bottom: -22px; z-index: 3; margin: 0 -22px -22px; padding: 13px 22px calc(13px + env(safe-area-inset-bottom, 0px)); background: rgba(251,252,250,.97); backdrop-filter: blur(8px); display: flex; justify-content: flex-end; gap: 8px; border-top: 1px solid #e2e7e2; box-shadow: 0 -8px 18px rgba(19,29,34,.06); }
|
|
175
|
+
.charlescms-actions button { border: 1px solid #cfd8d2; background: #f8faf7; color: var(--charlescms-ink); border-radius: 8px; padding: 11px 15px; min-height: 42px; font: inherit; font-weight: 850; cursor: pointer; touch-action: manipulation; }
|
|
176
|
+
.charlescms-actions [data-save], .charlescms-actions [data-save-section] { background: var(--charlescms-ink); color: white; border-color: var(--charlescms-ink); box-shadow: 0 10px 24px rgba(23,32,38,.16); }
|
|
177
|
+
.charlescms-actions .charlescms-danger-button { margin-right: auto; border-color: #edbbb2; color: var(--charlescms-danger); background: #fff4f1; }
|
|
178
|
+
.charlescms-actions button:disabled { cursor: not-allowed; opacity: .48; }
|
|
179
|
+
/* No inner scroll: the panel itself scrolls as one, between the pinned header
|
|
180
|
+
and the pinned action row — so there is never a tiny nested scrollbar to
|
|
181
|
+
fight, and Save is always reachable. */
|
|
182
|
+
.charlescms-frontmatter-fields { display: grid; gap: 12px; }
|
|
183
|
+
/* One quiet line under each Page-info field saying WHERE it shows up. */
|
|
184
|
+
.charlescms-field-hint { display: block; margin: 2px 0 4px; color: #93a09b; font-size: 11px; font-weight: 400; line-height: 1.4; }
|
|
185
|
+
/* Whisper-quiet provenance for developers (file · kind). Tucked at the very
|
|
186
|
+
bottom, tiny and faint, so a non-technical editor never registers it. */
|
|
187
|
+
.charlescms-panel-source { margin-top: -6px; color: #b3bdb8; text-align: right; font: 9.5px ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: .02em; user-select: all; }
|
|
188
|
+
.charlescms-data-group { margin: 16px 0 2px; padding-top: 12px; border-top: 1px solid rgba(0,0,0,.08); font-weight: 600; font-size: 13px; color: #1f2a24; }
|
|
189
|
+
.charlescms-data-block:first-child .charlescms-data-group { margin-top: 2px; border-top: 0; padding-top: 0; }
|
|
190
|
+
.charlescms-data-block { display: grid; gap: 12px; scroll-margin: 16px; border-radius: 8px; }
|
|
191
|
+
.charlescms-data-block.charlescms-flash { animation: charlescms-flash 1.6s ease; }
|
|
192
|
+
@keyframes charlescms-flash { 0%, 100% { background: transparent; } 22% { background: var(--charlescms-edit-soft); } }
|
|
193
|
+
/* Bridge elements carry data-charlescms-editable, so they use the uniform
|
|
194
|
+
hover hint above — no separate style needed. */
|
|
195
|
+
.charlescms-panel-note { margin: 0; color: #5d6b63; font-size: 12px; line-height: 1.45; }
|
|
196
|
+
.charlescms-media-tools code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 11px; }
|
|
197
|
+
.charlescms-versions-list { display: grid; gap: 10px; max-height: min(520px, calc(100vh - 160px)); overflow: auto; }
|
|
198
|
+
.charlescms-version { display: flex; justify-content: space-between; gap: 12px; align-items: center; padding: 12px 0; border-bottom: 1px solid #e2e7e2; }
|
|
199
|
+
.charlescms-version div { min-width: 0; display: grid; gap: 3px; }
|
|
200
|
+
.charlescms-version strong, .charlescms-version span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
201
|
+
.charlescms-version span { color: var(--charlescms-muted); font-size: 12px; }
|
|
202
|
+
.charlescms-version button { border: 1px solid var(--charlescms-ink); background: var(--charlescms-ink); color: white; border-radius: 7px; padding: 9px 11px; font: inherit; font-weight: 850; cursor: pointer; white-space: nowrap; }
|
|
203
|
+
.charlescms-staged-tag { flex: 0 0 auto; color: var(--charlescms-accent); font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .06em; white-space: nowrap; }
|
|
204
|
+
.charlescms-staged-go { flex: 0 0 auto; border: 1px solid #cfd8d2; background: #f8faf7; color: var(--charlescms-ink); border-radius: 7px; padding: 8px 11px; font: inherit; font-weight: 800; text-decoration: none; white-space: nowrap; }
|
|
205
|
+
.charlescms-staged-go:hover { border-color: var(--charlescms-accent); color: var(--charlescms-accent); }
|
|
206
|
+
|
|
207
|
+
/* ----- Publishing lock (deployed sites rebuild after a commit) ----- */
|
|
208
|
+
.charlescms-publishing { position: fixed; inset: 0; z-index: 2147483647; display: grid; place-items: center; background: rgba(18,25,30,.55); backdrop-filter: blur(3px); }
|
|
209
|
+
.charlescms-publishing-card { display: grid; justify-items: center; gap: 12px; max-width: min(420px, calc(100vw - 32px)); margin: 16px; padding: 28px 30px; text-align: center; background: rgba(251,252,250,.99); color: var(--charlescms-ink); border-radius: 16px; box-shadow: 0 32px 90px rgba(19,29,34,.32); font: 14px system-ui, sans-serif; }
|
|
210
|
+
.charlescms-publishing-card strong { font-size: 16px; font-weight: 700; }
|
|
211
|
+
.charlescms-publishing-card span { color: var(--charlescms-muted); line-height: 1.5; }
|
|
212
|
+
.charlescms-spinner { width: 30px; height: 30px; border-radius: 50%; border: 3px solid rgba(23,107,92,.2); border-top-color: var(--charlescms-accent); animation: charlescms-spin .8s linear infinite; }
|
|
213
|
+
@keyframes charlescms-spin { to { transform: rotate(360deg); } }
|
|
214
|
+
.charlescms-publishing-dismiss { margin-top: 4px; border: 1px solid #cfd8d2; background: #f8faf7; color: var(--charlescms-muted); border-radius: 8px; padding: 8px 14px; font: inherit; font-weight: 700; cursor: pointer; }
|
|
215
|
+
.charlescms-publishing-dismiss:hover { border-color: var(--charlescms-accent); color: var(--charlescms-accent); }
|
|
216
|
+
|
|
217
|
+
/* ----- Touch devices (fat-finger targets) ----- */
|
|
218
|
+
/* Anything driven by a coarse pointer gets finger-sized hit areas: a bigger
|
|
219
|
+
edit label, ≥44px action buttons, a larger close button and roomier inputs.
|
|
220
|
+
Keyed on the pointer, not width, so a touch laptop benefits too. */
|
|
221
|
+
@media (pointer: coarse) {
|
|
222
|
+
.charlescms-actions button { min-height: 46px; padding: 13px 18px; }
|
|
223
|
+
.charlescms-icon-button { width: 42px; height: 42px; }
|
|
224
|
+
.charlescms-panel input, .charlescms-panel textarea { padding: 13px 14px; font-size: 16px; }
|
|
225
|
+
.charlescms-toolbar button { padding: 10px 13px; }
|
|
226
|
+
.charlescms-version button, .charlescms-media-tools button { min-height: 44px; }
|
|
227
|
+
.charlescms-picker-item { min-height: 50px; }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* ----- Small screens ----- */
|
|
231
|
+
@media (max-width: 560px) {
|
|
232
|
+
.charlescms-panel { left: 12px; right: 12px; top: auto; width: auto; border-radius: 14px; }
|
|
233
|
+
.charlescms-toolbar { left: 12px; right: 12px; bottom: 12px; max-width: none; }
|
|
234
|
+
.charlescms-brand { order: -1; }
|
|
235
|
+
.charlescms-info { flex: 1 1 100%; order: -1; }
|
|
236
|
+
.charlescms-toolbar-actions { width: 100%; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); overflow: visible; }
|
|
237
|
+
.charlescms-toolbar-actions button { width: 100%; }
|
|
238
|
+
.charlescms-toolbar-actions button:last-child:nth-child(odd) { grid-column: 1 / -1; }
|
|
239
|
+
.charlescms-template-grid { grid-template-columns: 1fr; }
|
|
240
|
+
.charlescms-template-preview { min-height: 88px; max-height: 88px; }
|
|
241
|
+
.charlescms-section-gap { font-size: 10px; }
|
|
242
|
+
}
|
|
243
|
+
`;
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { mediaPreviewFor, mediaSrcValue } from "./media-preview.js";
|
|
2
|
+
|
|
3
|
+
// Builds the standard form editor for text, links, downloads, and media.
|
|
4
|
+
//
|
|
5
|
+
// This module translates a bound DOM element into a small editor model, renders
|
|
6
|
+
// the appropriate controls, and stages ordinary field edits. Rich text and
|
|
7
|
+
// sections use dedicated editors and are delegated through injected callbacks.
|
|
8
|
+
export function createElementEditor({
|
|
9
|
+
state,
|
|
10
|
+
closeEditor,
|
|
11
|
+
mountPanel,
|
|
12
|
+
stageEdit,
|
|
13
|
+
openRichTextInline,
|
|
14
|
+
openSectionBuilder,
|
|
15
|
+
uploadActiveMedia,
|
|
16
|
+
replaceDownloadFile,
|
|
17
|
+
removeActiveMedia,
|
|
18
|
+
downloadRepoPath,
|
|
19
|
+
escapeHtml,
|
|
20
|
+
escapeAttribute
|
|
21
|
+
}) {
|
|
22
|
+
function describeElement(element) {
|
|
23
|
+
const tag = element.tagName.toLowerCase();
|
|
24
|
+
const id = element.dataset.charlescmsId;
|
|
25
|
+
const fields = (element.dataset.charlescmsFields || "").split(",").filter(Boolean);
|
|
26
|
+
const entry = state.sourceMapById.get(id);
|
|
27
|
+
const operationKind = entry?.operations?.[0]?.kind;
|
|
28
|
+
|
|
29
|
+
if (operationKind === "block-html") {
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
element,
|
|
33
|
+
fields: ["body"],
|
|
34
|
+
type: "section",
|
|
35
|
+
value: { body: element.innerHTML },
|
|
36
|
+
label: element.querySelector("h2,h3,h4")?.textContent.trim().slice(0, 80) || "Section"
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
if (operationKind === "markdown-block") {
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
element,
|
|
43
|
+
fields: ["body"],
|
|
44
|
+
type: "richtext",
|
|
45
|
+
value: { text: element.innerHTML, body: element.innerHTML },
|
|
46
|
+
label: element.textContent.trim().slice(0, 80) || "Markdown"
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
if (operationKind === "markdown-text") {
|
|
50
|
+
const text = element.textContent.trim();
|
|
51
|
+
return { id, element, fields: ["text"], type: "text", value: { text }, label: text.slice(0, 80) };
|
|
52
|
+
}
|
|
53
|
+
// Static text interleaved with {expressions}: each static run is its own
|
|
54
|
+
// segment field. The value shown is the DECODED source text (so entities like
|
|
55
|
+
// © read naturally); the live expressions between segments stay locked.
|
|
56
|
+
if (fields.some((field) => /^seg\d+$/.test(field))) {
|
|
57
|
+
const segments = (entry?.operations || []).filter((operation) => /^seg\d+$/.test(operation.name));
|
|
58
|
+
const pending = state.pending.get(id)?.value || {};
|
|
59
|
+
const value = {};
|
|
60
|
+
for (const operation of segments) value[operation.name] = pending[operation.name] ?? decodeEntities(operation.oldValue);
|
|
61
|
+
return { id, element, fields: segments.map((operation) => operation.name), type: "segments", value, label: element.textContent.trim().slice(0, 80) || "Text" };
|
|
62
|
+
}
|
|
63
|
+
if (fields.includes("richtext")) {
|
|
64
|
+
return {
|
|
65
|
+
id,
|
|
66
|
+
element,
|
|
67
|
+
fields,
|
|
68
|
+
type: "richtext",
|
|
69
|
+
value: { text: element.innerHTML },
|
|
70
|
+
label: element.textContent.trim().slice(0, 80) || "Rich text"
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (tag === "img") {
|
|
74
|
+
return { id, element, fields, type: "image", value: readFields(element, fields), label: element.alt || "Image" };
|
|
75
|
+
}
|
|
76
|
+
if (["video", "audio", "iframe", "embed"].includes(tag)) {
|
|
77
|
+
return { id, element, fields, type: "asset", value: readFields(element, fields), label: tag };
|
|
78
|
+
}
|
|
79
|
+
if (tag === "object") {
|
|
80
|
+
return { id, element, fields, type: "asset", value: readFields(element, fields), label: "Object" };
|
|
81
|
+
}
|
|
82
|
+
if (tag === "a") {
|
|
83
|
+
const href = element.getAttribute("href") || "";
|
|
84
|
+
const isDownload = element.hasAttribute("download") || href.toLowerCase().endsWith(".pdf");
|
|
85
|
+
return {
|
|
86
|
+
id,
|
|
87
|
+
element,
|
|
88
|
+
fields,
|
|
89
|
+
type: isDownload ? "download" : "link",
|
|
90
|
+
value: readFields(element, fields),
|
|
91
|
+
label: element.textContent.trim() || href || "Link"
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const text = element.textContent.trim();
|
|
95
|
+
if (!text) return null;
|
|
96
|
+
return { id, element, fields, type: "text", value: readFields(element, fields), label: text.slice(0, 80) };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function openEditor(item) {
|
|
100
|
+
closeEditor();
|
|
101
|
+
revealEditable(item.element);
|
|
102
|
+
if (item.type === "section") {
|
|
103
|
+
openSectionBuilder({ item });
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (item.type === "richtext") {
|
|
107
|
+
openRichTextInline(item);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const dialog = document.createElement("div");
|
|
112
|
+
dialog.dataset.charlescmsUi = "true";
|
|
113
|
+
dialog.className = "charlescms-panel";
|
|
114
|
+
dialog.innerHTML = renderEditor(item);
|
|
115
|
+
mountPanel(dialog, { item });
|
|
116
|
+
|
|
117
|
+
dialog.querySelector("[data-save]")?.addEventListener("click", saveActive);
|
|
118
|
+
for (const close of dialog.querySelectorAll("[data-close]")) {
|
|
119
|
+
close.addEventListener("click", closeEditor);
|
|
120
|
+
}
|
|
121
|
+
dialog.querySelector("[data-upload]")?.addEventListener("change", uploadActiveMedia);
|
|
122
|
+
dialog.querySelector("[data-replace-download]")?.addEventListener("change", replaceDownloadFile);
|
|
123
|
+
dialog.querySelector("[data-remove]")?.addEventListener("click", removeActiveMedia);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function renderEditor(item) {
|
|
127
|
+
const header = `
|
|
128
|
+
<div class="charlescms-panel-header">
|
|
129
|
+
<div>
|
|
130
|
+
<div class="charlescms-kicker">${escapeHtml(item.type === "segments" ? "text" : item.type)}</div>
|
|
131
|
+
<div class="charlescms-title">${escapeHtml(editorTitle(item))}</div>
|
|
132
|
+
</div>
|
|
133
|
+
<button class="charlescms-icon-button" data-close aria-label="Close">×</button>
|
|
134
|
+
</div>
|
|
135
|
+
`;
|
|
136
|
+
if (item.type === "text") {
|
|
137
|
+
return `
|
|
138
|
+
${header}
|
|
139
|
+
<textarea data-field="text">${escapeHtml(item.value.text || "")}</textarea>
|
|
140
|
+
<div class="charlescms-actions"><button data-close>Cancel</button><button data-save>Save</button></div>
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
if (item.type === "segments") {
|
|
144
|
+
const single = item.fields.length === 1;
|
|
145
|
+
const inputs = item.fields.map((field, index) => {
|
|
146
|
+
const hint = single ? "Text" : `Text part ${index + 1}`;
|
|
147
|
+
return `<label>${hint}<textarea data-field="${field}">${escapeHtml(item.value[field] || "")}</textarea></label>`;
|
|
148
|
+
}).join("");
|
|
149
|
+
return `
|
|
150
|
+
${header}
|
|
151
|
+
${single ? "" : `<p class="charlescms-panel-subtitle">This text wraps a dynamic value, so it is edited in parts; the value between stays as is.</p>`}
|
|
152
|
+
${inputs}
|
|
153
|
+
<div class="charlescms-actions"><button data-close>Cancel</button><button data-save>Save</button></div>
|
|
154
|
+
`;
|
|
155
|
+
}
|
|
156
|
+
if (item.type === "link") {
|
|
157
|
+
return `
|
|
158
|
+
${header}
|
|
159
|
+
${renderFieldInputs(item)}
|
|
160
|
+
<div class="charlescms-actions"><button data-close>Cancel</button><button data-save>Save</button></div>
|
|
161
|
+
`;
|
|
162
|
+
}
|
|
163
|
+
if (item.type === "download") {
|
|
164
|
+
const hasText = item.fields.includes("text");
|
|
165
|
+
return `
|
|
166
|
+
${header}
|
|
167
|
+
${hasText ? renderFieldInputs(item) : ""}
|
|
168
|
+
${renderDownloadTools(item)}
|
|
169
|
+
<div class="charlescms-actions"><button data-close>${hasText ? "Cancel" : "Close"}</button>${hasText ? "<button data-save>Save</button>" : ""}</div>
|
|
170
|
+
`;
|
|
171
|
+
}
|
|
172
|
+
return `
|
|
173
|
+
${header}
|
|
174
|
+
${renderFieldInputs(item)}
|
|
175
|
+
${renderMediaTools(item)}
|
|
176
|
+
<div class="charlescms-actions"><button data-close>Cancel</button><button data-save>Save</button></div>
|
|
177
|
+
`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function saveActive() {
|
|
181
|
+
if (!state.active) return;
|
|
182
|
+
const { dialog, item } = state.active;
|
|
183
|
+
const value = Object.fromEntries(
|
|
184
|
+
[...dialog.querySelectorAll("[data-field]")].map((field) => [field.dataset.field, field.value])
|
|
185
|
+
);
|
|
186
|
+
const payload = {
|
|
187
|
+
id: item.id,
|
|
188
|
+
type: item.type,
|
|
189
|
+
route: location.pathname,
|
|
190
|
+
value,
|
|
191
|
+
label: item.label,
|
|
192
|
+
original: typeof item.value === "string" ? item.value : JSON.stringify(item.value)
|
|
193
|
+
};
|
|
194
|
+
stageEdit(item, value, payload);
|
|
195
|
+
applyValue(item.element, { ...payload, value });
|
|
196
|
+
closeEditor();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function revealEditable(element) {
|
|
200
|
+
const rect = element.getBoundingClientRect();
|
|
201
|
+
const toolbarTop = state.toolbar?.getBoundingClientRect().top || window.innerHeight;
|
|
202
|
+
const margin = 32;
|
|
203
|
+
if (rect.top < margin || rect.bottom > toolbarTop - margin) {
|
|
204
|
+
element.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function renderFieldInputs(item) {
|
|
209
|
+
const labels = {
|
|
210
|
+
alt: "Alt Text",
|
|
211
|
+
data: "File",
|
|
212
|
+
href: "URL",
|
|
213
|
+
poster: "Poster",
|
|
214
|
+
src: "File",
|
|
215
|
+
text: "Text",
|
|
216
|
+
title: "Title"
|
|
217
|
+
};
|
|
218
|
+
const hidePath = isMediaItem(item);
|
|
219
|
+
return item.fields.map((field) => {
|
|
220
|
+
const value = item.value[field] || "";
|
|
221
|
+
// Media paths are controlled by uploads. Keeping them hidden prevents a
|
|
222
|
+
// typo from turning a valid source edit into a broken public asset URL.
|
|
223
|
+
if (hidePath && mediaPathFields.has(field)) {
|
|
224
|
+
return `<input type="hidden" data-field="${field}" value="${escapeAttribute(value)}">`;
|
|
225
|
+
}
|
|
226
|
+
if (field === "text") {
|
|
227
|
+
return `<label>${labels[field]}<textarea data-field="${field}">${escapeHtml(value)}</textarea></label>`;
|
|
228
|
+
}
|
|
229
|
+
return `<label>${labels[field] || field}<input data-field="${field}" value="${escapeAttribute(value)}"></label>`;
|
|
230
|
+
}).join("");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function renderMediaTools(item) {
|
|
234
|
+
if (!isMediaItem(item)) return "";
|
|
235
|
+
if (state.previewOnly) {
|
|
236
|
+
return `
|
|
237
|
+
<div class="charlescms-media-tools">
|
|
238
|
+
<p>Uploads are disabled in this public demo. The real installed version can upload through your connector.</p>
|
|
239
|
+
<div>
|
|
240
|
+
<button type="button" data-upload-disabled disabled title="Uploads are disabled in this public demo">Upload replacement</button>
|
|
241
|
+
<button type="button" data-remove>Remove</button>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
`;
|
|
245
|
+
}
|
|
246
|
+
return `
|
|
247
|
+
<div class="charlescms-media-tools">
|
|
248
|
+
<label>Upload replacement<input type="file" data-upload></label>
|
|
249
|
+
<div><button type="button" data-remove>Remove</button></div>
|
|
250
|
+
</div>
|
|
251
|
+
`;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function renderDownloadTools(item) {
|
|
255
|
+
const repoPath = downloadRepoPath(item.element?.getAttribute("href") || item.element?.getAttribute("src") || "");
|
|
256
|
+
if (!repoPath) {
|
|
257
|
+
return '<div class="charlescms-media-tools"><p>This link points to an external or dynamic file, so it can\'t be replaced here.</p></div>';
|
|
258
|
+
}
|
|
259
|
+
if (state.previewOnly) {
|
|
260
|
+
return `
|
|
261
|
+
<div class="charlescms-media-tools">
|
|
262
|
+
<p>Uploads are disabled in this public demo. The real installed version can replace files through your connector.</p>
|
|
263
|
+
<div><button type="button" disabled title="Uploads are disabled in this public demo">Replace file</button></div>
|
|
264
|
+
</div>
|
|
265
|
+
`;
|
|
266
|
+
}
|
|
267
|
+
return `
|
|
268
|
+
<div class="charlescms-media-tools">
|
|
269
|
+
<label>Replace file<input type="file" data-replace-download></label>
|
|
270
|
+
<p>Uploads a new file in place of <code>${escapeHtml(repoPath)}</code>. The link keeps pointing here.</p>
|
|
271
|
+
</div>
|
|
272
|
+
`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return { describeElement, openEditor, applyValue, readFields };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const mediaPathFields = new Set(["src", "data", "poster", "href"]);
|
|
279
|
+
|
|
280
|
+
function editorTitle(item) {
|
|
281
|
+
if (item.type === "text" || item.type === "segments") return "Edit text";
|
|
282
|
+
if (item.type === "download") return item.element?.tagName === "IMG" ? "Replace image" : "Edit download";
|
|
283
|
+
if (item.type === "link") return "Edit link";
|
|
284
|
+
if (item.type === "image") return "Edit image";
|
|
285
|
+
if (item.type === "section") return "Edit section";
|
|
286
|
+
return "Edit asset";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function applyValue(element, edit) {
|
|
290
|
+
if (edit.type === "segments") {
|
|
291
|
+
// Update the live preview by replacing only each segment's old text inside
|
|
292
|
+
// the element — the rendered expression value between segments is left in
|
|
293
|
+
// place. Best-effort: if a segment's text isn't found verbatim (an entity
|
|
294
|
+
// that rendered differently), the preview just waits for the next reload.
|
|
295
|
+
let previous = {};
|
|
296
|
+
try { previous = JSON.parse(edit.original || "{}"); } catch { previous = {}; }
|
|
297
|
+
for (const [name, value] of Object.entries(edit.value)) replaceTextSegment(element, previous[name], value);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
if (Object.hasOwn(edit.value, "text")) {
|
|
301
|
+
if (edit.type === "richtext") {
|
|
302
|
+
element.innerHTML = edit.value.text;
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
element.textContent = edit.value.text;
|
|
306
|
+
}
|
|
307
|
+
for (const [name, value] of Object.entries(edit.value)) {
|
|
308
|
+
if (name === "text") continue;
|
|
309
|
+
if (name === "data" && element.tagName.toLowerCase() === "object") element.data = value;
|
|
310
|
+
else if (name === "src" && mediaPreviewFor(value)) {
|
|
311
|
+
// Show the locally-chosen file immediately; the committed path is remembered
|
|
312
|
+
// so reads and source writes still use it, not the display-only blob URL.
|
|
313
|
+
element.dataset.charlescmsMediaPath = value;
|
|
314
|
+
element.setAttribute("src", mediaPreviewFor(value));
|
|
315
|
+
} else element.setAttribute(name, value);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function readFields(element, fields) {
|
|
320
|
+
const value = {};
|
|
321
|
+
for (const field of fields) {
|
|
322
|
+
if (field === "text") value.text = element.textContent.trim();
|
|
323
|
+
else if (field === "data" && element.tagName.toLowerCase() === "object") value.data = element.data || "";
|
|
324
|
+
else if (field === "src") value.src = mediaSrcValue(element);
|
|
325
|
+
else value[field] = element.getAttribute(field) || "";
|
|
326
|
+
}
|
|
327
|
+
return value;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function isMediaItem(item) {
|
|
331
|
+
return item?.type === "image" || item?.type === "asset";
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Decode HTML entities in a source text run so the editor shows what the reader
|
|
335
|
+
// sees (`©` → `©`). On save the value is re-encoded by the source writer.
|
|
336
|
+
function decodeEntities(value) {
|
|
337
|
+
const element = document.createElement("textarea");
|
|
338
|
+
element.innerHTML = String(value ?? "");
|
|
339
|
+
return element.value;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Replace the first occurrence of `oldText` inside the element's text with
|
|
343
|
+
// `newText`, touching only that one text node — so a sibling expression's
|
|
344
|
+
// rendered value is never disturbed.
|
|
345
|
+
function replaceTextSegment(element, oldText, newText) {
|
|
346
|
+
if (!oldText) return;
|
|
347
|
+
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
|
|
348
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
349
|
+
const index = node.nodeValue.indexOf(oldText);
|
|
350
|
+
if (index !== -1) {
|
|
351
|
+
node.nodeValue = node.nodeValue.slice(0, index) + newText + node.nodeValue.slice(index + oldText.length);
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
package/src/fields.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Field labels are derived from analyzer operations so the browser editor knows
|
|
2
|
+
// which controls to show for each statically bundled source-map entry.
|
|
3
|
+
export function fieldName(operation) {
|
|
4
|
+
if (operation.kind === "rich-text") return "richtext";
|
|
5
|
+
return operation.name || operation.kind;
|
|
6
|
+
}
|