@morphika/andami 0.2.5 → 0.2.7

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,5 +1,5 @@
1
1
  import type { TextBlock } from "../../lib/sanity/types";
2
- import { PortableText } from "next-sanity";
2
+ import { PortableText, type PortableTextComponents } from "next-sanity";
3
3
 
4
4
  /** Resolve fontSize: supports numeric px and legacy string enum */
5
5
  function resolvePublicFontSize(fontSize?: number | string): string | undefined {
@@ -79,6 +79,31 @@ export function getTextBlockStyles(block: TextBlock): { className: string; style
79
79
  return { className: classes, style: inlineStyle };
80
80
  }
81
81
 
82
+ /** Custom Portable Text components for rich text rendering. */
83
+ const richTextComponents: PortableTextComponents = {
84
+ marks: {
85
+ link: ({ value, children }) => {
86
+ const href = value?.href || "#";
87
+ const blank = value?.blank;
88
+ return (
89
+ <a
90
+ href={href}
91
+ target={blank ? "_blank" : undefined}
92
+ rel={blank ? "noopener noreferrer" : undefined}
93
+ className="underline decoration-current underline-offset-2 hover:opacity-75 transition-opacity"
94
+ >
95
+ {children}
96
+ </a>
97
+ );
98
+ },
99
+ color: ({ value, children }) => {
100
+ const hex = value?.hex;
101
+ if (!hex) return <>{children}</>;
102
+ return <span style={{ color: hex }}>{children}</span>;
103
+ },
104
+ },
105
+ };
106
+
82
107
  export default function TextBlockRenderer({ block }: { block: TextBlock }) {
83
108
  if (!block.text?.length) return null;
84
109
 
@@ -89,7 +114,7 @@ export default function TextBlockRenderer({ block }: { block: TextBlock }) {
89
114
  className={`${className} space-y-[0.75em]`}
90
115
  style={style}
91
116
  >
92
- <PortableText value={block.text} />
117
+ <PortableText value={block.text} components={richTextComponents} />
93
118
  </div>
94
119
  );
95
120
  }
@@ -80,7 +80,7 @@ function wrapInMarks(
80
80
  result = <code>{result}</code>;
81
81
  break;
82
82
  default: {
83
- // Check if it's a link (markDef reference)
83
+ // Check if it's a markDef reference (link or color annotation)
84
84
  const def = getMarkDef(block, mark);
85
85
  if (def && def._type === "link" && def.href) {
86
86
  result = (
@@ -92,6 +92,12 @@ function wrapInMarks(
92
92
  {result}
93
93
  </a>
94
94
  );
95
+ } else if (def && def._type === "color" && def.hex) {
96
+ result = (
97
+ <span style={{ color: def.hex as string }}>
98
+ {result}
99
+ </span>
100
+ );
95
101
  }
96
102
  break;
97
103
  }
@@ -75,10 +75,10 @@ export default function CanvasToolbar({ viewportRef, onAnimatedAction }: CanvasT
75
75
  <div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-px rounded-full bg-[#1a1a1a] shadow-xl px-1 py-1"
76
76
  style={{ userSelect: "none" }}
77
77
  >
78
- {/* Select tool */}
78
+ {/* Select tool — Figma-style filled cursor */}
79
79
  <button
80
80
  onClick={() => setCanvasTool("select")}
81
- className={`flex items-center justify-center w-8 h-8 rounded-full text-xs transition-colors ${
81
+ className={`flex items-center justify-center w-8 h-8 rounded-full transition-colors ${
82
82
  tool === "select"
83
83
  ? "bg-white/15 text-white"
84
84
  : "text-neutral-400 hover:text-white hover:bg-white/10"
@@ -86,20 +86,15 @@ export default function CanvasToolbar({ viewportRef, onAnimatedAction }: CanvasT
86
86
  title="Select tool (V)"
87
87
  aria-label="Select tool"
88
88
  >
89
- <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
90
- <path
91
- d="M2 1L12 7L7 8L5 13L2 1Z"
92
- stroke="currentColor"
93
- strokeWidth="1.5"
94
- strokeLinejoin="round"
95
- />
89
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" strokeWidth="1.5" strokeLinejoin="round">
90
+ <path d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z" />
96
91
  </svg>
97
92
  </button>
98
93
 
99
- {/* Hand tool */}
94
+ {/* Hand tool — Figma-style */}
100
95
  <button
101
96
  onClick={() => setCanvasTool("hand")}
102
- className={`flex items-center justify-center w-8 h-8 rounded-full text-xs transition-colors ${
97
+ className={`flex items-center justify-center w-8 h-8 rounded-full transition-colors ${
103
98
  tool === "hand"
104
99
  ? "bg-white/15 text-white"
105
100
  : "text-neutral-400 hover:text-white hover:bg-white/10"
@@ -107,12 +102,11 @@ export default function CanvasToolbar({ viewportRef, onAnimatedAction }: CanvasT
107
102
  title="Hand tool (H)"
108
103
  aria-label="Hand tool"
109
104
  >
110
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
111
- {/* Five-finger open hand cleaner icon */}
112
- <path d="M18 11V6.5a1.5 1.5 0 0 0-3 0V11" />
113
- <path d="M15 9.5V4a1.5 1.5 0 0 0-3 0v7" />
114
- <path d="M12 11V5.5a1.5 1.5 0 0 0-3 0v6.5" />
115
- <path d="M9 11V9a1.5 1.5 0 0 0-3 0v3c0 4.42 2.69 8 6 8h1c3.31 0 6-3.58 6-8V11" />
105
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
106
+ <path d="M18 11V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2" />
107
+ <path d="M14 10V4a2 2 0 0 0-2-2a2 2 0 0 0-2 2v2" />
108
+ <path d="M10 10.5V6a2 2 0 0 0-2-2a2 2 0 0 0-2 2v8" />
109
+ <path d="M18 8a2 2 0 1 1 4 0v6a8 8 0 0 1-8 8h-2c-2.8 0-4.5-.86-5.99-2.34l-3.6-3.6a2 2 0 0 1 2.83-2.82L7 15" />
116
110
  </svg>
117
111
  </button>
118
112
 
@@ -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
+ }