@bygghammar/cms-platform 0.74.0 → 0.76.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,16 @@
1
+ ## 0.76.0
2
+
3
+ - Rich text editor colours: swatches are now derived from the consuming site's
4
+ design tokens (brand, accent, ink, paper, tint) plus a neutral row, a custom
5
+ hex field and a per-browser "recent colours" row.
6
+ - Rich text editor: toolbar buttons for a manual line break (Shift+Enter) and
7
+ "keep words together" (no wrap), so headings break exactly where you want.
8
+ - Sanitizer allows the `white-space` inline property used by the no-wrap mark.
9
+
10
+ ## 0.75.0
11
+
12
+ - Countdown: pick the replacement global block from a dropdown instead of typing its key.
13
+
1
14
  # Changelog
2
15
 
3
16
  ## 0.74.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bygghammar/cms-platform",
3
- "version": "0.74.0",
3
+ "version": "0.76.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": [
@@ -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
- const SWATCHES = [
54
- "#0f1720",
55
- "#4b5563",
56
- "#9ca3af",
57
- "#ffffff",
58
- "#2ac4ea",
59
- "#d8f2fd",
60
- "#afafaf",
61
- "#0ea5e9",
62
- "#16a34a",
63
- "#eab308",
64
- "#f97316",
65
- "#dc2626",
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-44 border border-ink/15 bg-white p-2 shadow-lg">
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
- {SWATCHES.map((c) => (
137
- <button
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
- <div className="mt-2 flex items-center gap-2">
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 || "#000000"}
155
- onChange={(e) => onPick(e.target.value)}
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,
@@ -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
+ }
@@ -2413,7 +2413,11 @@ export {
2413
2413
  };
2414
2414
 
2415
2415
  /* ---------------- Countdown ---------------- */
2416
- export function CountdownInspector({ block, patch }: BlockPatchProps) {
2416
+ export function CountdownInspector({
2417
+ block,
2418
+ patch,
2419
+ globalSections = [],
2420
+ }: BlockPatchProps & { globalSections?: { key: string; name: string }[] }) {
2417
2421
  const p = (block.props ?? {}) as CountdownProps;
2418
2422
  const update = (next: CountdownProps) => patch(next as unknown as BlockProps);
2419
2423
 
@@ -2711,11 +2715,37 @@ export function CountdownInspector({ block, patch }: BlockPatchProps) {
2711
2715
  (v) => update({ ...p, onExpire: v as CountdownProps["onExpire"] }),
2712
2716
  )}
2713
2717
  {p.onExpire === "replace" ? (
2714
- <TextField
2715
- label="Global block key to show instead"
2716
- value={p.expiredSectionKey ?? ""}
2717
- onChange={(v) => update({ ...p, expiredSectionKey: v })}
2718
- />
2718
+ globalSections.length ? (
2719
+ <label className="block space-y-1.5">
2720
+ <span className="block text-[0.7rem] uppercase tracking-[0.22em] text-ink/60">
2721
+ Global block to show instead
2722
+ </span>
2723
+ <select
2724
+ value={p.expiredSectionKey ?? ""}
2725
+ onChange={(e) => update({ ...p, expiredSectionKey: e.target.value })}
2726
+ className="w-full border border-ink/15 bg-white px-2.5 py-1.5 text-sm text-ink focus:border-sea focus:outline-none"
2727
+ >
2728
+ <option value="">— none —</option>
2729
+ {globalSections.map((g) => (
2730
+ <option key={g.key} value={g.key}>
2731
+ {g.name} ({g.key})
2732
+ </option>
2733
+ ))}
2734
+ {p.expiredSectionKey &&
2735
+ !globalSections.some((g) => g.key === p.expiredSectionKey) ? (
2736
+ <option value={p.expiredSectionKey}>
2737
+ {p.expiredSectionKey} (missing)
2738
+ </option>
2739
+ ) : null}
2740
+ </select>
2741
+ </label>
2742
+ ) : (
2743
+ <TextField
2744
+ label="Global block key to show instead"
2745
+ value={p.expiredSectionKey ?? ""}
2746
+ onChange={(v) => update({ ...p, expiredSectionKey: v })}
2747
+ />
2748
+ )
2719
2749
  ) : null}
2720
2750
  {p.onExpire === "revealPage" ? (
2721
2751
  <>
@@ -750,7 +750,7 @@ export function BlockInspector({
750
750
  case "courseList":
751
751
  return <TripListInspector block={block} patch={patch} />;
752
752
  case "countdown":
753
- return <CountdownInspector block={block} patch={patch} />;
753
+ return <CountdownInspector block={block} patch={patch} globalSections={globalSections} />;
754
754
  case "ctaBanner":
755
755
  return <CtaBannerInspector block={block} patch={patch} />;
756
756
  case "mediaSplit":
@@ -48,6 +48,7 @@ const ALLOWED_CSS = new Set([
48
48
  "letter-spacing",
49
49
  "text-decoration",
50
50
  "text-transform",
51
+ "white-space",
51
52
  "width",
52
53
  "height",
53
54
  "min-width",