@morphika/andami 0.2.4 → 0.2.6

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.
@@ -1,198 +1,117 @@
1
- "use client";
2
-
3
- import { useState, useRef, useCallback, useEffect } from "react";
4
- import { useBuilderStore } from "../../../lib/builder/store";
5
- import type { TextBlock, ContentBlock } from "../../../lib/sanity/types";
6
-
7
- /** Hook to get page-level text color for default block text rendering */
8
- export function usePageTextColor(): string {
9
- return useBuilderStore((s) => s.pageSettings.text_color) || "#0a0a0a";
10
- }
11
-
12
- /** Resolve fontSize: supports new numeric px and legacy string enum */
13
- function resolveTextFontSize(fontSize?: number | string): string {
14
- if (typeof fontSize === "number") return `${fontSize}px`;
15
- // Legacy string enum fallback
16
- const legacyMap: Record<string, string> = {
17
- small: "12px", base: "14px", large: "20px",
18
- xl: "24px", "2xl": "32px", "3xl": "48px",
19
- };
20
- return legacyMap[fontSize || "base"] || "14px";
21
- }
22
-
23
- /** Resolve fontWeight: supports new string numbers and legacy names */
24
- function resolveTextFontWeight(fw?: string): number {
25
- if (!fw) return 400;
26
- const num = parseInt(fw, 10);
27
- if (!isNaN(num)) return num;
28
- // Legacy name fallback
29
- if (fw === "bold") return 700;
30
- if (fw === "medium") return 500;
31
- return 400;
32
- }
33
-
34
- export default function LiveTextEditor({ block, editable = false }: { block: TextBlock; editable?: boolean }) {
35
- const pageTextColor = usePageTextColor();
36
- const store = useBuilderStore();
37
- const previewMode = useBuilderStore((s) => s.previewMode);
38
- const isSelected = useBuilderStore((s) => s.selectedBlockKey) === block._key;
39
- const editableRef = useRef<HTMLDivElement>(null);
40
- const isComposingRef = useRef(false);
41
- const snapshotPushedRef = useRef(false);
42
-
43
- const plainText = Array.isArray(block.text)
44
- ? block.text
45
- .map(
46
- (blk) =>
47
- blk?.children
48
- ?.map((c: { text?: string }) => c.text || "")
49
- .join("") || ""
50
- )
51
- .join("\n")
52
- : "";
53
-
54
- const style = block.style || {};
55
-
56
- // Debounce timer ref — commits text 500ms after last keystroke (not on every input)
57
- const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
58
-
59
- // Cleanup debounce timer on unmount
60
- useEffect(() => {
61
- return () => {
62
- if (debounceRef.current) clearTimeout(debounceRef.current);
63
- };
64
- }, []);
65
-
66
- // Sync content when block text changes externally (undo/redo)
67
- useEffect(() => {
68
- if (!editableRef.current || document.activeElement === editableRef.current) return;
69
- const currentHtml = editableRef.current.innerText || "";
70
- if (currentHtml !== plainText) {
71
- editableRef.current.innerText = plainText || "";
72
- }
73
- }, [plainText]);
74
-
75
- const commitText = useCallback(() => {
76
- if (!editableRef.current) return;
77
- const raw = editableRef.current.innerText || "";
78
- const lines = raw.split("\n");
79
- const portableText = lines.map((line, i) => ({
80
- _type: "block" as const,
81
- _key: `pt_${i}`,
82
- style: "normal" as const,
83
- markDefs: [],
84
- children: [
85
- {
86
- _type: "span" as const,
87
- _key: `sp_${i}`,
88
- text: line,
89
- marks: [],
90
- },
91
- ],
92
- }));
93
- store.updateBlockDebounced(block._key, {
94
- text: portableText,
95
- } as Partial<ContentBlock>);
96
- }, [block._key, store]);
97
-
98
- const handleFocus = useCallback(() => {
99
- if (!snapshotPushedRef.current) {
100
- store._pushSnapshot();
101
- snapshotPushedRef.current = true;
102
- }
103
- }, [store]);
104
-
105
- const handleBlur = useCallback(() => {
106
- // Flush any pending debounced commit immediately on blur
107
- if (debounceRef.current) {
108
- clearTimeout(debounceRef.current);
109
- debounceRef.current = null;
110
- }
111
- snapshotPushedRef.current = false;
112
- commitText();
113
- }, [commitText]);
114
-
115
- const handleInput = useCallback(() => {
116
- if (isComposingRef.current) return;
117
- // Clear any pending debounce and schedule a new one
118
- if (debounceRef.current) clearTimeout(debounceRef.current);
119
- debounceRef.current = setTimeout(() => {
120
- commitText();
121
- debounceRef.current = null;
122
- }, 500);
123
- }, [commitText]);
124
-
125
- const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
126
- // Stop propagation so builder shortcuts don't fire while typing
127
- if (!e.metaKey && !e.ctrlKey) {
128
- e.stopPropagation();
129
- }
130
- // Allow Ctrl+S, Ctrl+Z, Ctrl+Shift+Z to pass through
131
- if ((e.metaKey || e.ctrlKey) && (e.key === "s" || e.key === "z" || e.key === "Z" || e.key === "y")) {
132
- // Don't stop propagation for these
133
- return;
134
- }
135
- }, []);
136
-
137
- const cols = block.columns && block.columns > 1 ? block.columns : undefined;
138
-
139
- const computedStyle: React.CSSProperties = {
140
- fontSize: resolveTextFontSize(style.fontSize),
141
- fontWeight: resolveTextFontWeight(style.fontWeight),
142
- textAlign: style.alignment || "left",
143
- color: style.color || pageTextColor,
144
- opacity: style.opacity ?? 1,
145
- lineHeight: style.lineHeight || "1.6",
146
- letterSpacing: style.letterSpacing || "normal",
147
- maxWidth: style.maxWidth || "none",
148
- textTransform: style.textTransform || "none",
149
- fontFamily: "inherit",
150
- outline: "none",
151
- whiteSpace: "pre-wrap",
152
- wordBreak: "break-word",
153
- minHeight: "1em",
154
- // Multi-column layout: gap inherits global grid gutter
155
- ...(cols ? {
156
- columnCount: cols,
157
- columnGap: "var(--grid-gutter, 24px)",
158
- } : {}),
159
- };
160
-
161
- // Preview mode or read-only frame: static render
162
- if (previewMode || !editable) {
163
- if (!plainText) {
164
- return (
165
- <p className="text-neutral-400 italic text-sm py-4 text-center">
166
- Empty text block
167
- </p>
168
- );
169
- }
170
- return (
171
- <div style={computedStyle}>
172
- {plainText.split("\n").map((line, i) => (
173
- <p key={i} className={i > 0 ? "mt-3" : ""}>
174
- {line || "\u00A0"}
175
- </p>
176
- ))}
177
- </div>
178
- );
179
- }
180
-
181
- // Design mode: contentEditable inline editing
182
- return (
183
- <div
184
- ref={editableRef}
185
- contentEditable
186
- suppressContentEditableWarning
187
- onFocus={handleFocus}
188
- onBlur={handleBlur}
189
- onInput={handleInput}
190
- onKeyDown={handleKeyDown}
191
- onCompositionStart={() => { isComposingRef.current = true; }}
192
- onCompositionEnd={() => { isComposingRef.current = false; commitText(); }}
193
- style={computedStyle}
194
- data-placeholder="Type something..."
195
- className={`${!plainText && !isSelected ? "before:content-[attr(data-placeholder)] before:text-neutral-400 before:italic before:pointer-events-none" : ""}`}
196
- />
197
- );
198
- }
1
+ "use client";
2
+
3
+ /**
4
+ * LiveTextEditor.tsx
5
+ *
6
+ * Entry point for text block editing in the visual builder.
7
+ * Delegates to RichTextEditor (Tiptap-based) for editable mode,
8
+ * and renders a static rich text preview for read-only / preview modes
9
+ * using Sanity's PortableText renderer (preserves bold, italic, links, etc.).
10
+ *
11
+ * Preserves the original API surface (default export + usePageTextColor hook)
12
+ * so existing imports continue to work.
13
+ */
14
+
15
+ import { PortableText, type PortableTextComponents } from "next-sanity";
16
+ import { useBuilderStore } from "../../../lib/builder/store";
17
+ import RichTextEditor, { usePageTextColor } from "./RichTextEditor";
18
+ import type { TextBlock } from "../../../lib/sanity/types";
19
+
20
+ // Re-export the hook so barrel imports don't break
21
+ export { usePageTextColor };
22
+
23
+ /** Resolve fontSize: supports new numeric px and legacy string enum */
24
+ function resolveTextFontSize(fontSize?: number | string): string {
25
+ if (typeof fontSize === "number") return `${fontSize}px`;
26
+ const legacyMap: Record<string, string> = {
27
+ small: "12px", base: "14px", large: "20px",
28
+ xl: "24px", "2xl": "32px", "3xl": "48px",
29
+ };
30
+ return legacyMap[fontSize || "base"] || "14px";
31
+ }
32
+
33
+ /** Resolve fontWeight: supports new string numbers and legacy names */
34
+ function resolveTextFontWeight(fw?: string): number {
35
+ if (!fw) return 400;
36
+ const num = parseInt(fw, 10);
37
+ if (!isNaN(num)) return num;
38
+ if (fw === "bold") return 700;
39
+ if (fw === "medium") return 500;
40
+ return 400;
41
+ }
42
+
43
+ /** Portable Text components for builder preview — links non-navigable */
44
+ const previewRichTextComponents: PortableTextComponents = {
45
+ marks: {
46
+ link: ({ value, children }) => {
47
+ const href = value?.href || "#";
48
+ const blank = value?.blank;
49
+ return (
50
+ <a
51
+ href={href}
52
+ target={blank ? "_blank" : undefined}
53
+ rel={blank ? "noopener noreferrer" : undefined}
54
+ onClick={(e) => e.preventDefault()}
55
+ className="underline decoration-current underline-offset-2 cursor-default"
56
+ >
57
+ {children}
58
+ </a>
59
+ );
60
+ },
61
+ color: ({ value, children }) => {
62
+ const hex = value?.hex;
63
+ if (!hex) return <>{children}</>;
64
+ return <span style={{ color: hex }}>{children}</span>;
65
+ },
66
+ },
67
+ };
68
+
69
+ export default function LiveTextEditor({ block, editable = false }: { block: TextBlock; editable?: boolean }) {
70
+ const pageTextColor = usePageTextColor();
71
+ const previewMode = useBuilderStore((s) => s.previewMode);
72
+
73
+ const style = block.style || {};
74
+ const cols = block.columns && block.columns > 1 ? block.columns : undefined;
75
+
76
+ const hasText = Array.isArray(block.text) && block.text.length > 0 &&
77
+ block.text.some((blk) => blk?.children?.some((c: { text?: string }) => (c.text || "").length > 0));
78
+
79
+ const computedStyle: React.CSSProperties = {
80
+ fontSize: resolveTextFontSize(style.fontSize),
81
+ fontWeight: resolveTextFontWeight(style.fontWeight),
82
+ textAlign: style.alignment || "left",
83
+ color: style.color || pageTextColor,
84
+ opacity: style.opacity ?? 1,
85
+ lineHeight: style.lineHeight || "1.6",
86
+ letterSpacing: style.letterSpacing || "normal",
87
+ maxWidth: style.maxWidth || "none",
88
+ textTransform: style.textTransform || "none",
89
+ fontFamily: "inherit",
90
+ whiteSpace: "pre-wrap",
91
+ wordBreak: "break-word",
92
+ minHeight: "1em",
93
+ ...(cols ? {
94
+ columnCount: cols,
95
+ columnGap: "var(--grid-gutter, 24px)",
96
+ } : {}),
97
+ };
98
+
99
+ // Preview mode or read-only frame: static rich text render via PortableText
100
+ if (previewMode || !editable) {
101
+ if (!hasText) {
102
+ return (
103
+ <p className="text-neutral-400 italic text-sm py-4 text-center">
104
+ Empty text block
105
+ </p>
106
+ );
107
+ }
108
+ return (
109
+ <div style={computedStyle} className="space-y-[0.75em]">
110
+ <PortableText value={block.text!} components={previewRichTextComponents} />
111
+ </div>
112
+ );
113
+ }
114
+
115
+ // Design mode: Tiptap rich text editor
116
+ return <RichTextEditor block={block} editable />;
117
+ }