@morphika/andami 0.2.5 → 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.
- package/components/blocks/TextBlockRenderer.tsx +27 -2
- package/components/blocks/TypewriterRichText.tsx +7 -1
- package/components/builder/CanvasToolbar.tsx +11 -17
- package/components/builder/live-preview/LiveTextEditor.tsx +117 -198
- package/components/builder/live-preview/RichTextBubbleMenu.tsx +451 -0
- package/components/builder/live-preview/RichTextEditor.tsx +223 -0
- package/components/builder/live-preview/index.ts +2 -0
- package/lib/editor/index.ts +2 -0
- package/lib/editor/portableToTiptap.ts +156 -0
- package/lib/editor/tiptapToPortable.ts +238 -0
- package/package.json +223 -212
- package/sanity/schemas/blocks/textBlock.ts +13 -0
|
@@ -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
|
|
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
|
|
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="
|
|
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
|
|
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="
|
|
111
|
-
|
|
112
|
-
<path d="
|
|
113
|
-
<path d="
|
|
114
|
-
<path d="
|
|
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Resolve
|
|
24
|
-
function
|
|
25
|
-
if (
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
}
|