@bygghammar/cms-platform 0.75.0 → 0.77.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/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
## 0.77.0
|
|
2
|
+
|
|
3
|
+
- Global Sections list: preview thumbnails now use the section's first block type
|
|
4
|
+
and show a text snippet from the saved content (including Rich text `html`),
|
|
5
|
+
instead of a blank generic placeholder. Multi-block sections show a count badge.
|
|
6
|
+
- Shared thumbnail: text detection reads `html`, `body`, `lead`, `subtitle` and
|
|
7
|
+
`heading`, with locale fallback and HTML stripped to plain text.
|
|
8
|
+
|
|
9
|
+
## 0.76.0
|
|
10
|
+
|
|
11
|
+
- Rich text editor colours: swatches are now derived from the consuming site's
|
|
12
|
+
design tokens (brand, accent, ink, paper, tint) plus a neutral row, a custom
|
|
13
|
+
hex field and a per-browser "recent colours" row.
|
|
14
|
+
- Rich text editor: toolbar buttons for a manual line break (Shift+Enter) and
|
|
15
|
+
"keep words together" (no wrap), so headings break exactly where you want.
|
|
16
|
+
- Sanitizer allows the `white-space` inline property used by the no-wrap mark.
|
|
17
|
+
|
|
1
18
|
## 0.75.0
|
|
2
19
|
|
|
3
20
|
- Countdown: pick the replacement global block from a dropdown instead of typing its key.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.77.0",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": [
|
|
@@ -27,6 +27,24 @@ export type GlobalSectionsListDeps = {
|
|
|
27
27
|
previewSrc: (key: string) => string;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
/** Saved sections store an array of block instances; shapes may vary by age. */
|
|
31
|
+
function blockList(blocks: unknown): Record<string, unknown>[] {
|
|
32
|
+
if (!Array.isArray(blocks)) return [];
|
|
33
|
+
return blocks.filter((b): b is Record<string, unknown> => !!b && typeof b === "object");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function blockCount(blocks: unknown): number {
|
|
37
|
+
return blockList(blocks).length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Block type of the first block, so the thumbnail can draw its wireframe. */
|
|
41
|
+
function firstBlockType(blocks: unknown): string | undefined {
|
|
42
|
+
const first = blockList(blocks)[0];
|
|
43
|
+
const type = first?.["type"];
|
|
44
|
+
return typeof type === "string" && type ? type : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
30
48
|
/**
|
|
31
49
|
* Library screen listing global sections — reusable groups of blocks that update
|
|
32
50
|
* everywhere at once. All data access and navigation is host-supplied.
|
|
@@ -98,7 +116,12 @@ export function GlobalSectionsListView({ deps }: { deps: GlobalSectionsListDeps
|
|
|
98
116
|
onClick={() => setPreview({ key: g.key, name: g.name })}
|
|
99
117
|
className="group relative block"
|
|
100
118
|
>
|
|
101
|
-
<DataThumb data={g.blocks} />
|
|
119
|
+
<DataThumb data={g.blocks} blockType={firstBlockType(g.blocks)} />
|
|
120
|
+
{blockCount(g.blocks) > 1 && (
|
|
121
|
+
<span className="absolute bottom-1 right-1 rounded bg-ink/70 px-1 text-[0.55rem] leading-4 text-white">
|
|
122
|
+
{blockCount(g.blocks)}
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
102
125
|
<span className="absolute inset-0 hidden items-center justify-center bg-ink/50 text-white group-hover:flex">
|
|
103
126
|
<Eye className="size-4" />
|
|
104
127
|
</span>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
-
import { EditorContent, useEditor, type Editor } from "@tiptap/react";
|
|
2
|
+
import { EditorContent, Extension, useEditor, type Editor } from "@tiptap/react";
|
|
3
3
|
import StarterKit from "@tiptap/starter-kit";
|
|
4
4
|
import Image from "@tiptap/extension-image";
|
|
5
5
|
import TextAlign from "@tiptap/extension-text-align";
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
Minus,
|
|
27
27
|
Quote,
|
|
28
28
|
Redo2,
|
|
29
|
+
WrapText,
|
|
29
30
|
Strikethrough,
|
|
30
31
|
Subscript as SubIcon,
|
|
31
32
|
Superscript as SupIcon,
|
|
@@ -33,9 +34,18 @@ import {
|
|
|
33
34
|
Underline as UnderlineIcon,
|
|
34
35
|
Undo2,
|
|
35
36
|
Unlink,
|
|
37
|
+
MoveHorizontal,
|
|
36
38
|
} from "lucide-react";
|
|
37
39
|
import { MediaPickerDialog } from "./inspectors/fields";
|
|
38
40
|
import { sanitizeHtml } from "../html/sanitize-html";
|
|
41
|
+
import {
|
|
42
|
+
NEUTRAL_SWATCHES,
|
|
43
|
+
normalizeHex,
|
|
44
|
+
readRecentColors,
|
|
45
|
+
rememberRecentColor,
|
|
46
|
+
resolveThemeSwatches,
|
|
47
|
+
type EditorSwatch,
|
|
48
|
+
} from "./editor-colors";
|
|
39
49
|
|
|
40
50
|
const FONTS: { label: string; value: string }[] = [
|
|
41
51
|
{ label: "Theme (default)", value: "" },
|
|
@@ -50,20 +60,28 @@ const FONTS: { label: string; value: string }[] = [
|
|
|
50
60
|
|
|
51
61
|
const SIZES = ["", "12px", "14px", "16px", "18px", "20px", "24px", "30px", "36px", "48px", "60px"];
|
|
52
62
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
63
|
+
/**
|
|
64
|
+
* `white-space` on the textStyle mark, so a selection can be marked
|
|
65
|
+
* "keep words together" and never wrap mid-phrase.
|
|
66
|
+
*/
|
|
67
|
+
const WhiteSpace = Extension.create({
|
|
68
|
+
name: "whiteSpaceTextStyle",
|
|
69
|
+
addGlobalAttributes() {
|
|
70
|
+
return [
|
|
71
|
+
{
|
|
72
|
+
types: ["textStyle"],
|
|
73
|
+
attributes: {
|
|
74
|
+
whiteSpace: {
|
|
75
|
+
default: null,
|
|
76
|
+
parseHTML: (element: HTMLElement) => element.style.whiteSpace || null,
|
|
77
|
+
renderHTML: (attributes: Record<string, unknown>) =>
|
|
78
|
+
attributes.whiteSpace ? { style: `white-space: ${String(attributes.whiteSpace)}` } : {},
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
];
|
|
83
|
+
},
|
|
84
|
+
});
|
|
67
85
|
|
|
68
86
|
function Btn({
|
|
69
87
|
on,
|
|
@@ -101,6 +119,26 @@ function Divider() {
|
|
|
101
119
|
return <span className="mx-1 h-5 w-px bg-ink/10" />;
|
|
102
120
|
}
|
|
103
121
|
|
|
122
|
+
function SwatchButton({
|
|
123
|
+
swatch,
|
|
124
|
+
onPick,
|
|
125
|
+
}: {
|
|
126
|
+
swatch: EditorSwatch;
|
|
127
|
+
onPick: (hex: string) => void;
|
|
128
|
+
}) {
|
|
129
|
+
return (
|
|
130
|
+
<button
|
|
131
|
+
type="button"
|
|
132
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
133
|
+
onClick={() => onPick(swatch.hex)}
|
|
134
|
+
className="size-5 border border-ink/15"
|
|
135
|
+
style={{ background: swatch.hex }}
|
|
136
|
+
title={`${swatch.label} (${swatch.hex})`}
|
|
137
|
+
aria-label={`${swatch.label} ${swatch.hex}`}
|
|
138
|
+
/>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
104
142
|
function ColorControl({
|
|
105
143
|
label,
|
|
106
144
|
value,
|
|
@@ -113,11 +151,36 @@ function ColorControl({
|
|
|
113
151
|
onClear: () => void;
|
|
114
152
|
}) {
|
|
115
153
|
const [open, setOpen] = useState(false);
|
|
154
|
+
const [theme, setTheme] = useState<EditorSwatch[]>([]);
|
|
155
|
+
const [recent, setRecent] = useState<string[]>([]);
|
|
156
|
+
const [hexDraft, setHexDraft] = useState("");
|
|
157
|
+
const [hexError, setHexError] = useState(false);
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
if (!open) return;
|
|
161
|
+
setTheme(resolveThemeSwatches());
|
|
162
|
+
setRecent(readRecentColors());
|
|
163
|
+
setHexDraft(normalizeHex(value) ?? "");
|
|
164
|
+
setHexError(false);
|
|
165
|
+
}, [open, value]);
|
|
166
|
+
|
|
167
|
+
const apply = (hex: string, close = true) => {
|
|
168
|
+
const normalized = normalizeHex(hex);
|
|
169
|
+
if (!normalized) {
|
|
170
|
+
setHexError(true);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
onPick(normalized);
|
|
174
|
+
setRecent(rememberRecentColor(normalized));
|
|
175
|
+
setHexError(false);
|
|
176
|
+
if (close) setOpen(false);
|
|
177
|
+
};
|
|
178
|
+
|
|
116
179
|
return (
|
|
117
180
|
<div className="relative">
|
|
118
181
|
<button
|
|
119
182
|
type="button"
|
|
120
|
-
title={label}
|
|
183
|
+
title={label === "A" ? "Text colour" : "Highlight colour"}
|
|
121
184
|
onMouseDown={(e) => e.preventDefault()}
|
|
122
185
|
onClick={() => setOpen((o) => !o)}
|
|
123
186
|
className="flex h-7 items-center gap-1 border border-transparent px-1.5 text-[0.65rem] uppercase tracking-widest text-ink hover:border-ink/20"
|
|
@@ -131,29 +194,79 @@ function ColorControl({
|
|
|
131
194
|
{open ? (
|
|
132
195
|
<>
|
|
133
196
|
<div className="fixed inset-0 z-40" onClick={() => setOpen(false)} />
|
|
134
|
-
<div className="absolute left-0 top-8 z-50 w-
|
|
197
|
+
<div className="absolute left-0 top-8 z-50 w-56 border border-ink/15 bg-white p-2 shadow-lg">
|
|
198
|
+
{theme.length ? (
|
|
199
|
+
<>
|
|
200
|
+
<p className="mb-1 text-[0.6rem] uppercase tracking-widest text-ink/50">
|
|
201
|
+
Site colours
|
|
202
|
+
</p>
|
|
203
|
+
<div className="grid grid-cols-6 gap-1">
|
|
204
|
+
{theme.map((sw) => (
|
|
205
|
+
<SwatchButton key={`theme-${sw.label}`} swatch={sw} onPick={apply} />
|
|
206
|
+
))}
|
|
207
|
+
</div>
|
|
208
|
+
</>
|
|
209
|
+
) : null}
|
|
210
|
+
|
|
211
|
+
<p className="mb-1 mt-2 text-[0.6rem] uppercase tracking-widest text-ink/50">
|
|
212
|
+
Neutral
|
|
213
|
+
</p>
|
|
135
214
|
<div className="grid grid-cols-6 gap-1">
|
|
136
|
-
{
|
|
137
|
-
<
|
|
138
|
-
key={c}
|
|
139
|
-
type="button"
|
|
140
|
-
onMouseDown={(e) => e.preventDefault()}
|
|
141
|
-
onClick={() => {
|
|
142
|
-
onPick(c);
|
|
143
|
-
setOpen(false);
|
|
144
|
-
}}
|
|
145
|
-
className="size-5 border border-ink/15"
|
|
146
|
-
style={{ background: c }}
|
|
147
|
-
title={c}
|
|
148
|
-
/>
|
|
215
|
+
{NEUTRAL_SWATCHES.map((sw) => (
|
|
216
|
+
<SwatchButton key={sw.hex} swatch={sw} onPick={apply} />
|
|
149
217
|
))}
|
|
150
218
|
</div>
|
|
151
|
-
|
|
219
|
+
|
|
220
|
+
{recent.length ? (
|
|
221
|
+
<>
|
|
222
|
+
<p className="mb-1 mt-2 text-[0.6rem] uppercase tracking-widest text-ink/50">
|
|
223
|
+
Recent
|
|
224
|
+
</p>
|
|
225
|
+
<div className="grid grid-cols-6 gap-1">
|
|
226
|
+
{recent.map((hex) => (
|
|
227
|
+
<SwatchButton key={`recent-${hex}`} swatch={{ hex, label: "Recent" }} onPick={apply} />
|
|
228
|
+
))}
|
|
229
|
+
</div>
|
|
230
|
+
</>
|
|
231
|
+
) : null}
|
|
232
|
+
|
|
233
|
+
<p className="mb-1 mt-2 text-[0.6rem] uppercase tracking-widest text-ink/50">
|
|
234
|
+
Custom
|
|
235
|
+
</p>
|
|
236
|
+
<div className="flex items-center gap-2">
|
|
152
237
|
<input
|
|
153
238
|
type="color"
|
|
154
|
-
value={value
|
|
155
|
-
onChange={(e) =>
|
|
239
|
+
value={normalizeHex(value) ?? "#000000"}
|
|
240
|
+
onChange={(e) => {
|
|
241
|
+
setHexDraft(e.target.value);
|
|
242
|
+
apply(e.target.value, false);
|
|
243
|
+
}}
|
|
156
244
|
className="h-6 w-10 border border-ink/15 bg-white"
|
|
245
|
+
title="Colour picker"
|
|
246
|
+
/>
|
|
247
|
+
<input
|
|
248
|
+
type="text"
|
|
249
|
+
value={hexDraft}
|
|
250
|
+
placeholder="#fc7aa7"
|
|
251
|
+
spellCheck={false}
|
|
252
|
+
onChange={(e) => {
|
|
253
|
+
setHexDraft(e.target.value);
|
|
254
|
+
setHexError(false);
|
|
255
|
+
}}
|
|
256
|
+
onKeyDown={(e) => {
|
|
257
|
+
if (e.key === "Enter") {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
apply(hexDraft);
|
|
260
|
+
}
|
|
261
|
+
}}
|
|
262
|
+
onBlur={() => {
|
|
263
|
+
if (hexDraft.trim()) apply(hexDraft, false);
|
|
264
|
+
}}
|
|
265
|
+
className={[
|
|
266
|
+
"h-6 w-20 border bg-white px-1 text-xs text-ink focus:outline-none",
|
|
267
|
+
hexError ? "border-red-500" : "border-ink/15 focus:border-sea",
|
|
268
|
+
].join(" ")}
|
|
269
|
+
aria-label="Custom hex colour"
|
|
157
270
|
/>
|
|
158
271
|
<button
|
|
159
272
|
type="button"
|
|
@@ -167,6 +280,9 @@ function ColorControl({
|
|
|
167
280
|
Clear
|
|
168
281
|
</button>
|
|
169
282
|
</div>
|
|
283
|
+
{hexError ? (
|
|
284
|
+
<p className="mt-1 text-[0.6rem] text-red-600">Enter a hex colour like #fc7aa7.</p>
|
|
285
|
+
) : null}
|
|
170
286
|
</div>
|
|
171
287
|
</>
|
|
172
288
|
) : null}
|
|
@@ -335,6 +451,28 @@ function Toolbar({ editor }: { editor: Editor }) {
|
|
|
335
451
|
/>
|
|
336
452
|
<Divider />
|
|
337
453
|
|
|
454
|
+
<Btn
|
|
455
|
+
on={() => editor.chain().focus().setHardBreak().run()}
|
|
456
|
+
title="Line break (Shift+Enter)"
|
|
457
|
+
>
|
|
458
|
+
<WrapText className="size-3.5" />
|
|
459
|
+
</Btn>
|
|
460
|
+
<Btn
|
|
461
|
+
on={() => {
|
|
462
|
+
const active = editor.getAttributes("textStyle").whiteSpace === "nowrap";
|
|
463
|
+
editor
|
|
464
|
+
.chain()
|
|
465
|
+
.focus()
|
|
466
|
+
.setMark("textStyle", { whiteSpace: active ? null : "nowrap" })
|
|
467
|
+
.run();
|
|
468
|
+
}}
|
|
469
|
+
active={editor.getAttributes("textStyle").whiteSpace === "nowrap"}
|
|
470
|
+
title="Keep words together (no line wrap)"
|
|
471
|
+
>
|
|
472
|
+
<MoveHorizontal className="size-3.5" />
|
|
473
|
+
</Btn>
|
|
474
|
+
<Divider />
|
|
475
|
+
|
|
338
476
|
<Btn
|
|
339
477
|
on={() => editor.chain().focus().setTextAlign("left").run()}
|
|
340
478
|
active={editor.isActive({ textAlign: "left" })}
|
|
@@ -531,6 +669,7 @@ export function RichTextEditor({
|
|
|
531
669
|
FontFamily,
|
|
532
670
|
FontSize,
|
|
533
671
|
LineHeight,
|
|
672
|
+
WhiteSpace,
|
|
534
673
|
Highlight.configure({ multicolor: true }),
|
|
535
674
|
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
|
536
675
|
Subscript,
|
|
@@ -64,6 +64,42 @@ function findImage(node: unknown, depth = 0): string | null {
|
|
|
64
64
|
return null;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
const ENTITIES: Record<string, string> = {
|
|
68
|
+
" ": " ",
|
|
69
|
+
"&": "&",
|
|
70
|
+
"<": "<",
|
|
71
|
+
">": ">",
|
|
72
|
+
""": '"',
|
|
73
|
+
"'": "'",
|
|
74
|
+
"'": "'",
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Plain-text snippet from sanitized editor HTML (no DOM needed, SSR-safe). */
|
|
78
|
+
function htmlToText(html: string): string {
|
|
79
|
+
const withBreaks = html.replace(/<(br|\/p|\/h[1-6]|\/li|\/div)[^>]*>/gi, " ");
|
|
80
|
+
const stripped = withBreaks.replace(/<[^>]*>/g, "");
|
|
81
|
+
const decoded = stripped
|
|
82
|
+
.replace(/ |&|<|>|"|'|'/gi, (m) => ENTITIES[m.toLowerCase()] ?? m)
|
|
83
|
+
.replace(/&#(\d+);/g, (_, code: string) => String.fromCodePoint(Number(code)));
|
|
84
|
+
return decoded.replace(/\s+/g, " ").trim();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Text keys, in the order a block would show them. */
|
|
88
|
+
const TEXT_KEYS = ["title", "heading", "name", "label", "eyebrow", "html", "body", "lead", "subtitle"];
|
|
89
|
+
|
|
90
|
+
/** Picks a string out of a plain string or a localized map (active locale first). */
|
|
91
|
+
function localizedString(value: unknown): string | null {
|
|
92
|
+
if (typeof value === "string") return value.trim() ? value : null;
|
|
93
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
94
|
+
const map = value as Record<string, unknown>;
|
|
95
|
+
const ordered = ["sv", "en", ...Object.keys(map)];
|
|
96
|
+
for (const locale of ordered) {
|
|
97
|
+
const candidate = map[locale];
|
|
98
|
+
if (typeof candidate === "string" && candidate.trim()) return candidate;
|
|
99
|
+
}
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
67
103
|
function findText(node: unknown, depth = 0): string | null {
|
|
68
104
|
if (node == null || depth > 4) return null;
|
|
69
105
|
if (Array.isArray(node)) {
|
|
@@ -75,13 +111,16 @@ function findText(node: unknown, depth = 0): string | null {
|
|
|
75
111
|
}
|
|
76
112
|
if (typeof node !== "object") return null;
|
|
77
113
|
const obj = node as Record<string, unknown>;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
114
|
+
const nested = obj["props"];
|
|
115
|
+
if (nested && typeof nested === "object") {
|
|
116
|
+
const found = findText(nested, depth + 1);
|
|
117
|
+
if (found) return found;
|
|
118
|
+
}
|
|
119
|
+
for (const key of TEXT_KEYS) {
|
|
120
|
+
const raw = localizedString(obj[key]);
|
|
121
|
+
if (!raw) continue;
|
|
122
|
+
const text = key === "html" || /<[a-z][^>]*>/i.test(raw) ? htmlToText(raw) : raw.trim();
|
|
123
|
+
if (text) return text.length > 120 ? `${text.slice(0, 120)}…` : text;
|
|
85
124
|
}
|
|
86
125
|
return null;
|
|
87
126
|
}
|
|
@@ -372,7 +411,7 @@ export function DataThumb({
|
|
|
372
411
|
|
|
373
412
|
return (
|
|
374
413
|
<div className="relative shrink-0 border border-ink/10 bg-white overflow-hidden" style={style}>
|
|
375
|
-
{text
|
|
414
|
+
{text ? (
|
|
376
415
|
<div className="flex size-full flex-col justify-center gap-1.5 p-2">
|
|
377
416
|
<div className="h-1 w-6 bg-sea/60" />
|
|
378
417
|
<div className="text-[0.5rem] leading-tight text-ink/70 line-clamp-2 font-serif">
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour palette helpers for the rich text editor.
|
|
3
|
+
*
|
|
4
|
+
* The editor swatches are derived from the *consuming site's* design tokens, so
|
|
5
|
+
* every site automatically offers its own brand colours without configuration.
|
|
6
|
+
* Tokens may be authored in any colour space (oklch, hsl, hex), so values are
|
|
7
|
+
* resolved through the browser and normalised to hex, which is what the colour
|
|
8
|
+
* input and the stored inline styles need.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type EditorSwatch = { hex: string; label: string };
|
|
12
|
+
|
|
13
|
+
/** Site design tokens, in the order they should appear in the picker. */
|
|
14
|
+
const THEME_TOKENS: { token: string; label: string; fallback: string }[] = [
|
|
15
|
+
{ token: "--sea", label: "Brand", fallback: "#fc7aa7" },
|
|
16
|
+
{ token: "--brass", label: "Accent", fallback: "#ca6286" },
|
|
17
|
+
{ token: "--ink", label: "Ink", fallback: "#221b26" },
|
|
18
|
+
{ token: "--paper", label: "Paper", fallback: "#fdf7f8" },
|
|
19
|
+
{ token: "--ice", label: "Tint", fallback: "#f8e7ec" },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
/** Neutral + status colours that stay available on every site. */
|
|
23
|
+
export const NEUTRAL_SWATCHES: EditorSwatch[] = [
|
|
24
|
+
{ hex: "#000000", label: "Black" },
|
|
25
|
+
{ hex: "#4b5563", label: "Dark grey" },
|
|
26
|
+
{ hex: "#9ca3af", label: "Grey" },
|
|
27
|
+
{ hex: "#e5e7eb", label: "Light grey" },
|
|
28
|
+
{ hex: "#ffffff", label: "White" },
|
|
29
|
+
{ hex: "#16a34a", label: "Green" },
|
|
30
|
+
{ hex: "#eab308", label: "Yellow" },
|
|
31
|
+
{ hex: "#f97316", label: "Orange" },
|
|
32
|
+
{ hex: "#dc2626", label: "Red" },
|
|
33
|
+
{ hex: "#0ea5e9", label: "Blue" },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
const HEX_RE = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
37
|
+
|
|
38
|
+
/** Normalise user input such as `fc7aa7` or `#FFF` to `#rrggbb`. */
|
|
39
|
+
export function normalizeHex(raw: string): string | null {
|
|
40
|
+
const v = raw.trim().toLowerCase();
|
|
41
|
+
const withHash = v.startsWith("#") ? v : `#${v}`;
|
|
42
|
+
if (!HEX_RE.test(withHash)) return null;
|
|
43
|
+
if (withHash.length === 4) {
|
|
44
|
+
const [, r, g, b] = withHash;
|
|
45
|
+
return `#${r}${r}${g}${g}${b}${b}`;
|
|
46
|
+
}
|
|
47
|
+
return withHash;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function toHex(cssColor: string): string | null {
|
|
51
|
+
if (!cssColor) return null;
|
|
52
|
+
const direct = normalizeHex(cssColor);
|
|
53
|
+
if (direct) return direct;
|
|
54
|
+
try {
|
|
55
|
+
const canvas = document.createElement("canvas");
|
|
56
|
+
canvas.width = 1;
|
|
57
|
+
canvas.height = 1;
|
|
58
|
+
const ctx = canvas.getContext("2d");
|
|
59
|
+
if (!ctx) return null;
|
|
60
|
+
ctx.fillStyle = "#000000";
|
|
61
|
+
ctx.fillStyle = cssColor;
|
|
62
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
63
|
+
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
|
|
64
|
+
const hex = (n: number) => n.toString(16).padStart(2, "0");
|
|
65
|
+
return `#${hex(r)}${hex(g)}${hex(b)}`;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Resolve the site theme swatches from the live CSS custom properties. */
|
|
72
|
+
export function resolveThemeSwatches(): EditorSwatch[] {
|
|
73
|
+
if (typeof document === "undefined") {
|
|
74
|
+
return THEME_TOKENS.map((t) => ({ hex: t.fallback, label: t.label }));
|
|
75
|
+
}
|
|
76
|
+
const probe = document.createElement("span");
|
|
77
|
+
probe.style.position = "absolute";
|
|
78
|
+
probe.style.opacity = "0";
|
|
79
|
+
probe.style.pointerEvents = "none";
|
|
80
|
+
document.body.appendChild(probe);
|
|
81
|
+
try {
|
|
82
|
+
return THEME_TOKENS.map((t) => {
|
|
83
|
+
probe.style.color = "";
|
|
84
|
+
probe.style.color = `var(${t.token})`;
|
|
85
|
+
const computed = getComputedStyle(probe).color;
|
|
86
|
+
return { hex: toHex(computed) ?? t.fallback, label: t.label };
|
|
87
|
+
});
|
|
88
|
+
} catch {
|
|
89
|
+
return THEME_TOKENS.map((t) => ({ hex: t.fallback, label: t.label }));
|
|
90
|
+
} finally {
|
|
91
|
+
probe.remove();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const RECENT_KEY = "cms.rte.recent-colors";
|
|
96
|
+
const RECENT_MAX = 8;
|
|
97
|
+
|
|
98
|
+
export function readRecentColors(): string[] {
|
|
99
|
+
if (typeof localStorage === "undefined") return [];
|
|
100
|
+
try {
|
|
101
|
+
const raw = localStorage.getItem(RECENT_KEY);
|
|
102
|
+
if (!raw) return [];
|
|
103
|
+
const parsed: unknown = JSON.parse(raw);
|
|
104
|
+
if (!Array.isArray(parsed)) return [];
|
|
105
|
+
return parsed
|
|
106
|
+
.filter((v): v is string => typeof v === "string")
|
|
107
|
+
.map((v) => normalizeHex(v))
|
|
108
|
+
.filter((v): v is string => Boolean(v))
|
|
109
|
+
.slice(0, RECENT_MAX);
|
|
110
|
+
} catch {
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function rememberRecentColor(hex: string): string[] {
|
|
116
|
+
const normalized = normalizeHex(hex);
|
|
117
|
+
if (!normalized) return readRecentColors();
|
|
118
|
+
const next = [normalized, ...readRecentColors().filter((c) => c !== normalized)].slice(
|
|
119
|
+
0,
|
|
120
|
+
RECENT_MAX,
|
|
121
|
+
);
|
|
122
|
+
if (typeof localStorage !== "undefined") {
|
|
123
|
+
try {
|
|
124
|
+
localStorage.setItem(RECENT_KEY, JSON.stringify(next));
|
|
125
|
+
} catch {
|
|
126
|
+
/* storage unavailable — recents are a convenience only */
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return next;
|
|
130
|
+
}
|