@aphexcms/cms-core 9.0.0 → 9.2.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.
Files changed (114) hide show
  1. package/dist/cli/generate-types.d.ts.map +1 -1
  2. package/dist/cli/generate-types.js +197 -8
  3. package/dist/cli/generate-types.js.map +1 -1
  4. package/dist/components/AdminApp.svelte +46 -137
  5. package/dist/components/AdminApp.svelte.d.ts.map +1 -1
  6. package/dist/components/admin/DocumentEditor.svelte +237 -206
  7. package/dist/components/admin/DocumentEditor.svelte.d.ts.map +1 -1
  8. package/dist/components/admin/DocumentVersionPanel.svelte +3 -2
  9. package/dist/components/admin/DocumentVersionPanel.svelte.d.ts.map +1 -1
  10. package/dist/components/admin/MediaBrowser.svelte +22 -4
  11. package/dist/components/admin/MediaBrowser.svelte.d.ts.map +1 -1
  12. package/dist/components/admin/ObjectModal.svelte +62 -75
  13. package/dist/components/admin/ObjectModal.svelte.d.ts.map +1 -1
  14. package/dist/components/admin/SchemaField.svelte +2 -2
  15. package/dist/components/admin/fields/ArrayField.svelte +9 -1
  16. package/dist/components/admin/fields/ArrayField.svelte.d.ts.map +1 -1
  17. package/dist/components/admin/fields/ReferenceField.svelte +37 -8
  18. package/dist/components/admin/fields/ReferenceField.svelte.d.ts.map +1 -1
  19. package/dist/components/admin/fields/richtext/PortableTextImageView.svelte +116 -0
  20. package/dist/components/admin/fields/richtext/PortableTextImageView.svelte.d.ts +12 -0
  21. package/dist/components/admin/fields/richtext/PortableTextImageView.svelte.d.ts.map +1 -0
  22. package/dist/components/admin/fields/richtext/PortableTextInlineView.svelte +111 -0
  23. package/dist/components/admin/fields/richtext/PortableTextInlineView.svelte.d.ts +12 -0
  24. package/dist/components/admin/fields/richtext/PortableTextInlineView.svelte.d.ts.map +1 -0
  25. package/dist/components/admin/fields/richtext/PortableTextObjectView.svelte +67 -0
  26. package/dist/components/admin/fields/richtext/PortableTextObjectView.svelte.d.ts +12 -0
  27. package/dist/components/admin/fields/richtext/PortableTextObjectView.svelte.d.ts.map +1 -0
  28. package/dist/components/admin/fields/richtext/RichtextField.svelte +1314 -0
  29. package/dist/components/admin/fields/richtext/RichtextField.svelte.d.ts +15 -0
  30. package/dist/components/admin/fields/richtext/RichtextField.svelte.d.ts.map +1 -0
  31. package/dist/components/admin/fields/richtext/custom-annotation-mark.d.ts +7 -0
  32. package/dist/components/admin/fields/richtext/custom-annotation-mark.d.ts.map +1 -0
  33. package/dist/components/admin/fields/richtext/custom-annotation-mark.js +33 -0
  34. package/dist/components/admin/fields/richtext/portable-text-inline-object.d.ts +11 -0
  35. package/dist/components/admin/fields/richtext/portable-text-inline-object.d.ts.map +1 -0
  36. package/dist/components/admin/fields/richtext/portable-text-inline-object.js +31 -0
  37. package/dist/components/admin/fields/richtext/portable-text-object-node.d.ts +11 -0
  38. package/dist/components/admin/fields/richtext/portable-text-object-node.d.ts.map +1 -0
  39. package/dist/components/admin/fields/richtext/portable-text-object-node.js +31 -0
  40. package/dist/components/admin/fields/richtext/portable-text-serializer.d.ts +38 -0
  41. package/dist/components/admin/fields/richtext/portable-text-serializer.d.ts.map +1 -0
  42. package/dist/components/admin/fields/richtext/portable-text-serializer.js +318 -0
  43. package/dist/components/admin/fields/richtext/svelte-inline-node-view.d.ts +8 -0
  44. package/dist/components/admin/fields/richtext/svelte-inline-node-view.d.ts.map +1 -0
  45. package/dist/components/admin/fields/richtext/svelte-inline-node-view.js +67 -0
  46. package/dist/components/admin/fields/richtext/svelte-node-view.d.ts +8 -0
  47. package/dist/components/admin/fields/richtext/svelte-node-view.d.ts.map +1 -0
  48. package/dist/components/admin/fields/richtext/svelte-node-view.js +63 -0
  49. package/dist/db/interfaces/document.d.ts +8 -2
  50. package/dist/db/interfaces/document.d.ts.map +1 -1
  51. package/dist/graphql/resolvers.d.ts.map +1 -1
  52. package/dist/graphql/resolvers.js +13 -11
  53. package/dist/graphql/schema.d.ts.map +1 -1
  54. package/dist/graphql/schema.js +7 -3
  55. package/dist/lib/components/admin/fields/richtext/custom-annotation-mark.d.ts +7 -0
  56. package/dist/lib/components/admin/fields/richtext/custom-annotation-mark.d.ts.map +1 -0
  57. package/dist/lib/components/admin/fields/richtext/custom-annotation-mark.js +34 -0
  58. package/dist/lib/components/admin/fields/richtext/custom-annotation-mark.js.map +1 -0
  59. package/dist/lib/components/admin/fields/richtext/portable-text-inline-object.d.ts +11 -0
  60. package/dist/lib/components/admin/fields/richtext/portable-text-inline-object.d.ts.map +1 -0
  61. package/dist/lib/components/admin/fields/richtext/portable-text-inline-object.js +32 -0
  62. package/dist/lib/components/admin/fields/richtext/portable-text-inline-object.js.map +1 -0
  63. package/dist/lib/components/admin/fields/richtext/portable-text-object-node.d.ts +11 -0
  64. package/dist/lib/components/admin/fields/richtext/portable-text-object-node.d.ts.map +1 -0
  65. package/dist/lib/components/admin/fields/richtext/portable-text-object-node.js +32 -0
  66. package/dist/lib/components/admin/fields/richtext/portable-text-object-node.js.map +1 -0
  67. package/dist/lib/components/admin/fields/richtext/portable-text-serializer.d.ts +38 -0
  68. package/dist/lib/components/admin/fields/richtext/portable-text-serializer.d.ts.map +1 -0
  69. package/dist/lib/components/admin/fields/richtext/portable-text-serializer.js +319 -0
  70. package/dist/lib/components/admin/fields/richtext/portable-text-serializer.js.map +1 -0
  71. package/dist/lib/components/admin/fields/richtext/svelte-inline-node-view.d.ts +8 -0
  72. package/dist/lib/components/admin/fields/richtext/svelte-inline-node-view.d.ts.map +1 -0
  73. package/dist/lib/components/admin/fields/richtext/svelte-inline-node-view.js +68 -0
  74. package/dist/lib/components/admin/fields/richtext/svelte-inline-node-view.js.map +1 -0
  75. package/dist/lib/components/admin/fields/richtext/svelte-node-view.d.ts +8 -0
  76. package/dist/lib/components/admin/fields/richtext/svelte-node-view.d.ts.map +1 -0
  77. package/dist/lib/components/admin/fields/richtext/svelte-node-view.js +64 -0
  78. package/dist/lib/components/admin/fields/richtext/svelte-node-view.js.map +1 -0
  79. package/dist/lib/db/interfaces/document.d.ts +8 -2
  80. package/dist/lib/db/interfaces/document.d.ts.map +1 -1
  81. package/dist/lib/graphql/resolvers.d.ts.map +1 -1
  82. package/dist/lib/graphql/resolvers.js +13 -11
  83. package/dist/lib/graphql/resolvers.js.map +1 -1
  84. package/dist/lib/graphql/schema.d.ts.map +1 -1
  85. package/dist/lib/graphql/schema.js +7 -3
  86. package/dist/lib/graphql/schema.js.map +1 -1
  87. package/dist/lib/local-api/collection-api.d.ts.map +1 -1
  88. package/dist/lib/local-api/collection-api.js +7 -0
  89. package/dist/lib/local-api/collection-api.js.map +1 -1
  90. package/dist/lib/schema-utils/validator.d.ts.map +1 -1
  91. package/dist/lib/schema-utils/validator.js +5 -1
  92. package/dist/lib/schema-utils/validator.js.map +1 -1
  93. package/dist/lib/server/api/routes/assets-by-id.d.ts.map +1 -1
  94. package/dist/lib/server/api/routes/assets-by-id.js +10 -2
  95. package/dist/lib/server/api/routes/assets-by-id.js.map +1 -1
  96. package/dist/lib/server/api/routes/assets-references.d.ts.map +1 -1
  97. package/dist/lib/server/api/routes/assets-references.js +6 -4
  98. package/dist/lib/server/api/routes/assets-references.js.map +1 -1
  99. package/dist/lib/types/schemas.d.ts +27 -0
  100. package/dist/lib/types/schemas.d.ts.map +1 -1
  101. package/dist/lib/types/schemas.js +3 -1
  102. package/dist/lib/types/schemas.js.map +1 -1
  103. package/dist/local-api/collection-api.d.ts.map +1 -1
  104. package/dist/local-api/collection-api.js +7 -0
  105. package/dist/schema-utils/validator.d.ts.map +1 -1
  106. package/dist/schema-utils/validator.js +5 -1
  107. package/dist/server/api/routes/assets-by-id.d.ts.map +1 -1
  108. package/dist/server/api/routes/assets-by-id.js +10 -2
  109. package/dist/server/api/routes/assets-references.d.ts.map +1 -1
  110. package/dist/server/api/routes/assets-references.js +6 -4
  111. package/dist/types/schemas.d.ts +27 -0
  112. package/dist/types/schemas.d.ts.map +1 -1
  113. package/dist/types/schemas.js +3 -1
  114. package/package.json +6 -1
@@ -0,0 +1,1314 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import { Editor } from '@tiptap/core';
4
+ import { StarterKit } from '@tiptap/starter-kit';
5
+ import Link from '@tiptap/extension-link';
6
+ import Underline from '@tiptap/extension-underline';
7
+ import {
8
+ Bold,
9
+ Italic,
10
+ Underline as UnderlineIcon,
11
+ Strikethrough,
12
+ Code,
13
+ List,
14
+ ListOrdered,
15
+ Link as LinkIcon,
16
+ Unlink,
17
+ Undo2,
18
+ Redo2,
19
+ Plus,
20
+ ChevronDown,
21
+ X,
22
+ Copy,
23
+ Pencil,
24
+ Maximize2,
25
+ Minimize2
26
+ } from '@lucide/svelte';
27
+ import {
28
+ tiptapToPortableText,
29
+ portableTextToTiptap,
30
+ type PortableTextValue
31
+ } from './portable-text-serializer';
32
+ import { PortableTextObject } from './portable-text-object-node';
33
+ import { PortableTextInlineObject } from './portable-text-inline-object';
34
+ import { createAnnotationMark } from './custom-annotation-mark';
35
+ import type { ArrayField as ArrayFieldType, SchemaType } from '../../../../types/schemas';
36
+ import { getSchemaContext } from '../../../../schema-context.svelte';
37
+ import { getSchemaByName } from '../../../../schema-utils/utils';
38
+ import ObjectModal from '../../ObjectModal.svelte';
39
+ import AssetBrowserModal from '../../AssetBrowserModal.svelte';
40
+ import * as Tooltip from '@aphexcms/ui/shadcn/tooltip';
41
+ import { Image as ImageIcon } from '@lucide/svelte';
42
+
43
+ interface Props {
44
+ field: ArrayFieldType;
45
+ value: PortableTextValue | null | undefined;
46
+ onUpdate: (value: PortableTextValue) => void;
47
+ validationClasses?: string;
48
+ readonly?: boolean;
49
+ onOpenReference?: (documentId: string, documentType: string) => void;
50
+ organizationId?: string;
51
+ }
52
+
53
+ let {
54
+ field,
55
+ value,
56
+ onUpdate,
57
+ validationClasses = '',
58
+ readonly = false,
59
+ onOpenReference,
60
+ organizationId
61
+ }: Props = $props();
62
+
63
+ const schemas = getSchemaContext();
64
+
65
+ let element = $state<HTMLElement>();
66
+ let editor = $state<Editor | null>(null);
67
+ let editorState = $state(0);
68
+ let internalVersion = 0;
69
+ let isSyncingFromParent = false;
70
+
71
+ let expanded = $state(false);
72
+ let containerBounds = $state<{ top: number; left: number; width: number; height: number } | null>(
73
+ null
74
+ );
75
+ let insertMenuOpen = $state(false);
76
+ let modalOpen = $state(false);
77
+ let editingSchema = $state<SchemaType | null>(null);
78
+ let editingValue = $state<Record<string, any>>({});
79
+ let editingNodeKey = $state<string | null>(null);
80
+ let editingIsInline = $state(false);
81
+
82
+ const blockDef = $derived(field.of.find((ref) => ref.type === 'block'));
83
+ const styles = $derived(
84
+ blockDef?.styles?.map((s) => s.value) ?? [
85
+ 'normal',
86
+ 'h1',
87
+ 'h2',
88
+ 'h3',
89
+ 'h4',
90
+ 'h5',
91
+ 'h6',
92
+ 'blockquote'
93
+ ]
94
+ );
95
+ const decorators = $derived(
96
+ blockDef?.marks?.decorators?.map((d) => d.value) ?? [
97
+ 'strong',
98
+ 'em',
99
+ 'underline',
100
+ 'strike-through',
101
+ 'code'
102
+ ]
103
+ );
104
+ const lists = $derived(blockDef?.lists?.map((l) => l.value) ?? ['bullet', 'number']);
105
+
106
+ const customAnnotations = $derived(
107
+ (blockDef?.marks?.annotations ?? []).map((a) => ({
108
+ name: a.name,
109
+ title: a.title || a.name,
110
+ icon: a.icon,
111
+ fields: a.fields || []
112
+ }))
113
+ );
114
+ const hasAnnotations = $derived(customAnnotations.length > 0);
115
+ const hasLink = true;
116
+
117
+ const hasImageBlock = $derived(field.of.some((ref) => ref.type === 'image'));
118
+ const customTypes = $derived(
119
+ field.of
120
+ .filter((ref) => ref.type !== 'block' && ref.type !== 'image')
121
+ .map((t) => {
122
+ const registered = getSchemaByName(schemas, t.type);
123
+ return {
124
+ type: t.type,
125
+ title: t.title || registered?.title || t.type,
126
+ fields: t.fields || registered?.fields || [],
127
+ schema: registered
128
+ };
129
+ })
130
+ );
131
+ const hasCustomTypes = $derived(customTypes.length > 0);
132
+
133
+ const inlineTypes = $derived(
134
+ (blockDef?.of ?? []).map((t) => {
135
+ const registered = getSchemaByName(schemas, t.type);
136
+ return {
137
+ type: t.type,
138
+ title: t.title || registered?.title || t.type,
139
+ fields: t.fields || registered?.fields || [],
140
+ schema: registered
141
+ };
142
+ })
143
+ );
144
+ const hasInlineTypes = $derived(inlineTypes.length > 0);
145
+
146
+ const hasDecorator = (d: string) => decorators.includes(d);
147
+ const hasList = (l: string) => lists.includes(l);
148
+ const hasStyle = (s: string) => styles.includes(s);
149
+
150
+ let styleMenuOpen = $state(false);
151
+
152
+ const styleLabels: Record<string, string> = {
153
+ normal: 'Normal',
154
+ h1: 'Heading 1',
155
+ h2: 'Heading 2',
156
+ h3: 'Heading 3',
157
+ h4: 'Heading 4',
158
+ h5: 'Heading 5',
159
+ h6: 'Heading 6',
160
+ blockquote: 'Quote'
161
+ };
162
+
163
+ const activeStyle = $derived.by(() => {
164
+ void editorState;
165
+ if (!editor) return 'normal';
166
+ for (const s of styles) {
167
+ if (s === 'blockquote' && editor.isActive('blockquote')) return 'blockquote';
168
+ if (s.startsWith('h') && editor.isActive('heading', { level: parseInt(s.slice(1), 10) }))
169
+ return s;
170
+ }
171
+ return 'normal';
172
+ });
173
+
174
+ function applyStyle(style: string) {
175
+ if (!editor) return;
176
+ if (style === 'normal') {
177
+ editor.chain().focus().setParagraph().run();
178
+ } else if (style === 'blockquote') {
179
+ editor.chain().focus().toggleBlockquote().run();
180
+ } else if (style.startsWith('h')) {
181
+ const level = parseInt(style.slice(1), 10) as 1 | 2 | 3 | 4 | 5 | 6;
182
+ editor.chain().focus().toggleHeading({ level }).run();
183
+ }
184
+ styleMenuOpen = false;
185
+ }
186
+
187
+ function genKey(): string {
188
+ return Math.random().toString(36).slice(2, 8);
189
+ }
190
+
191
+ function handleEditBlock(attrs: { _type: string; _key: string; data: Record<string, unknown> }) {
192
+ if (attrs._type === 'image') {
193
+ editingNodeKey = attrs._key;
194
+ showAssetBrowser = true;
195
+ return;
196
+ }
197
+ const typeDef = customTypes.find((t) => t.type === attrs._type);
198
+ if (!typeDef) return;
199
+ editingSchema = {
200
+ type: 'object',
201
+ name: attrs._type,
202
+ title: typeDef.title,
203
+ fields: typeDef.fields as any
204
+ };
205
+ editingValue = { ...attrs.data };
206
+ editingNodeKey = attrs._key;
207
+ editingIsInline = false;
208
+ modalOpen = true;
209
+ }
210
+
211
+ function handleDeleteBlock(key: string) {
212
+ if (!editor) return;
213
+ const { state } = editor;
214
+ const { tr } = state;
215
+ state.doc.descendants((node, pos) => {
216
+ if (
217
+ (node.type.name === 'portableTextObject' ||
218
+ node.type.name === 'portableTextInlineObject') &&
219
+ node.attrs._key === key
220
+ ) {
221
+ tr.delete(pos, pos + node.nodeSize);
222
+ return false;
223
+ }
224
+ });
225
+ editor.view.dispatch(tr);
226
+ }
227
+
228
+ function handleInsertBlock(typeName: string) {
229
+ if (!editor) return;
230
+ const typeDef = customTypes.find((t) => t.type === typeName);
231
+ if (!typeDef) return;
232
+ const key = genKey();
233
+ editingSchema = {
234
+ type: 'object',
235
+ name: typeName,
236
+ title: typeDef.title,
237
+ fields: typeDef.fields as any
238
+ };
239
+ editingValue = {};
240
+ editingNodeKey = key;
241
+ editingIsInline = false;
242
+ modalOpen = true;
243
+ insertMenuOpen = false;
244
+ }
245
+
246
+ function handleEditInline(attrs: { _type: string; _key: string; data: Record<string, unknown> }) {
247
+ const typeDef = inlineTypes.find((t) => t.type === attrs._type);
248
+ if (!typeDef) return;
249
+ editingSchema = {
250
+ type: 'object',
251
+ name: attrs._type,
252
+ title: typeDef.title,
253
+ fields: typeDef.fields as any
254
+ };
255
+ editingValue = { ...attrs.data };
256
+ editingNodeKey = attrs._key;
257
+ editingIsInline = true;
258
+ modalOpen = true;
259
+ }
260
+
261
+ function handleInsertInline(typeName: string) {
262
+ if (!editor) return;
263
+ const typeDef = inlineTypes.find((t) => t.type === typeName);
264
+ if (!typeDef) return;
265
+ const key = genKey();
266
+ editingSchema = {
267
+ type: 'object',
268
+ name: typeName,
269
+ title: typeDef.title,
270
+ fields: typeDef.fields as any
271
+ };
272
+ editingValue = {};
273
+ editingNodeKey = key;
274
+ editingIsInline = true;
275
+ modalOpen = true;
276
+ insertMenuOpen = false;
277
+ }
278
+
279
+ let showAssetBrowser = $state(false);
280
+
281
+ function handleInsertImageBlock() {
282
+ editingNodeKey = null;
283
+ insertMenuOpen = false;
284
+ showAssetBrowser = true;
285
+ }
286
+
287
+ function handleAssetSelected(asset: any) {
288
+ if (!editor || !asset) return;
289
+
290
+ const existing = editingNodeKey ? findNodeByKey(editingNodeKey) : null;
291
+ if (existing) {
292
+ const { tr } = editor.state;
293
+ tr.setNodeMarkup(existing.pos, undefined, {
294
+ _type: 'image',
295
+ _key: editingNodeKey,
296
+ data: { asset: { _type: 'reference', _ref: asset.id } }
297
+ });
298
+ editor.view.dispatch(tr);
299
+ } else {
300
+ editor
301
+ .chain()
302
+ .focus()
303
+ .insertContent({
304
+ type: 'portableTextObject',
305
+ attrs: {
306
+ _type: 'image',
307
+ _key: genKey(),
308
+ data: { asset: { _type: 'reference', _ref: asset.id } }
309
+ }
310
+ })
311
+ .run();
312
+ }
313
+
314
+ showAssetBrowser = false;
315
+ editingNodeKey = null;
316
+ }
317
+
318
+ function handleModalUpdate(updatedData: Record<string, any>) {
319
+ editingValue = updatedData;
320
+ if (!editor || !editingSchema || !editingNodeKey) return;
321
+ const existing = findNodeByKey(editingNodeKey);
322
+ if (existing) {
323
+ const { tr } = editor.state;
324
+ tr.setNodeMarkup(existing.pos, undefined, {
325
+ _type: editingSchema.name,
326
+ _key: editingNodeKey,
327
+ data: editingValue
328
+ });
329
+ editor.view.dispatch(tr);
330
+ }
331
+ }
332
+
333
+ function handleModalClose() {
334
+ if (!editor || !editingSchema || !editingNodeKey) {
335
+ modalOpen = false;
336
+ return;
337
+ }
338
+ const existing = findNodeByKey(editingNodeKey);
339
+ if (existing) {
340
+ const { tr } = editor.state;
341
+ tr.setNodeMarkup(existing.pos, undefined, {
342
+ _type: editingSchema.name,
343
+ _key: editingNodeKey,
344
+ data: editingValue
345
+ });
346
+ editor.view.dispatch(tr);
347
+ } else {
348
+ const nodeType = editingIsInline ? 'portableTextInlineObject' : 'portableTextObject';
349
+ editor
350
+ .chain()
351
+ .focus()
352
+ .insertContent({
353
+ type: nodeType,
354
+ attrs: {
355
+ _type: editingSchema.name,
356
+ _key: editingNodeKey,
357
+ data: editingValue
358
+ }
359
+ })
360
+ .run();
361
+ }
362
+ modalOpen = false;
363
+ editingSchema = null;
364
+ editingValue = {};
365
+ editingNodeKey = null;
366
+ editingIsInline = false;
367
+ }
368
+
369
+ function findNodeByKey(key: string): { pos: number; node: any } | null {
370
+ if (!editor) return null;
371
+ let found: { pos: number; node: any } | null = null;
372
+ editor.state.doc.descendants((node, pos) => {
373
+ if (
374
+ (node.type.name === 'portableTextObject' ||
375
+ node.type.name === 'portableTextInlineObject') &&
376
+ node.attrs._key === key
377
+ ) {
378
+ found = { pos, node };
379
+ return false;
380
+ }
381
+ });
382
+ return found;
383
+ }
384
+
385
+ function createEditor() {
386
+ editor?.destroy();
387
+ if (!element) return;
388
+ const tiptapDoc = portableTextToTiptap(value || []);
389
+ const starterKitConfig: Record<string, any> = {
390
+ heading: {
391
+ levels: [1, 2, 3, 4, 5, 6].filter((l) => hasStyle(`h${l}`))
392
+ },
393
+ bulletList: hasList('bullet') ? {} : false,
394
+ orderedList: hasList('number') ? {} : false,
395
+ blockquote: hasStyle('blockquote') ? {} : false,
396
+ bold: hasDecorator('strong') ? {} : false,
397
+ italic: hasDecorator('em') ? {} : false,
398
+ strike: hasDecorator('strike-through') ? {} : false,
399
+ code: hasDecorator('code') ? {} : false
400
+ };
401
+ // Prevent duplicates — StarterKit may bundle these in newer versions
402
+ if (hasDecorator('underline')) starterKitConfig.underline = false;
403
+ if (hasLink) starterKitConfig.link = false;
404
+
405
+ const extensions = [
406
+ StarterKit.configure(starterKitConfig),
407
+ ...(hasDecorator('underline') ? [Underline] : []),
408
+ ...(hasLink
409
+ ? [
410
+ Link.configure({
411
+ openOnClick: false,
412
+ HTMLAttributes: { class: 'richtext-link' }
413
+ })
414
+ ]
415
+ : []),
416
+ ...(hasCustomTypes
417
+ ? [
418
+ PortableTextObject.configure({
419
+ onEdit: handleEditBlock,
420
+ onDelete: handleDeleteBlock
421
+ })
422
+ ]
423
+ : []),
424
+ ...(hasInlineTypes
425
+ ? [
426
+ PortableTextInlineObject.configure({
427
+ onEdit: handleEditInline,
428
+ onDelete: handleDeleteBlock
429
+ })
430
+ ]
431
+ : []),
432
+ ...customAnnotations.map((a) => createAnnotationMark(a.name))
433
+ ];
434
+ internalVersion = 0;
435
+ lastSyncedVersion = 0;
436
+ editorJustCreated = true;
437
+ expanded = false;
438
+ editor = new Editor({
439
+ element,
440
+ editable: !readonly,
441
+ extensions,
442
+ content: tiptapDoc,
443
+ onUpdate: ({ editor: e }) => {
444
+ if (isSyncingFromParent) return;
445
+ internalVersion++;
446
+ const json = e.getJSON();
447
+ const pt = tiptapToPortableText(json);
448
+ onUpdate(pt);
449
+ },
450
+ onTransaction: ({ editor: e }) => {
451
+ editor = e as any;
452
+ queueMicrotask(() => {
453
+ editorState++;
454
+ });
455
+ if (linkMode === 'edit') return;
456
+ if (e.isActive('link') && e.state.selection.empty) {
457
+ showLinkPreview();
458
+ } else if (linkMode === 'preview') {
459
+ linkMode = 'closed';
460
+ }
461
+ },
462
+ injectCSS: false
463
+ });
464
+ }
465
+
466
+ function handleAnnotationClick(e: MouseEvent) {
467
+ const target = e.target as HTMLElement;
468
+ const annotationEl = target.closest('[data-annotation]');
469
+ if (!annotationEl || !editor) return;
470
+ const name = annotationEl.getAttribute('data-annotation');
471
+ if (!name) return;
472
+
473
+ const def = customAnnotations.find((a) => a.name === name);
474
+ if (!def) return;
475
+
476
+ const markName = `annotation_${name}`;
477
+ const markType = editor.schema.marks[markName];
478
+ if (!markType) return;
479
+
480
+ const pos = editor.view.posAtDOM(annotationEl, 0);
481
+ const resolved = editor.state.doc.resolve(pos);
482
+ const node = resolved.parent.maybeChild(resolved.index());
483
+ const mark = node?.marks.find((m: any) => m.type === markType);
484
+
485
+ if (mark) {
486
+ annotationValue = { ...(mark.attrs.data || {}) };
487
+ const key = mark.attrs._key || genKey();
488
+ annotationKey = key;
489
+ const range = findMarkRange(key, markType);
490
+ annotationRange = range || { from: pos, to: pos + (node?.nodeSize || 0) };
491
+ } else {
492
+ annotationValue = {};
493
+ annotationKey = genKey();
494
+ annotationRange = { from: pos, to: pos };
495
+ }
496
+ annotationDef = def;
497
+ annotationModalOpen = true;
498
+ }
499
+
500
+ onMount(() => {
501
+ createEditor();
502
+ document.addEventListener('click', handleClickOutside);
503
+ document.addEventListener('keydown', handleKeydown);
504
+ element?.addEventListener('click', handleAnnotationClick);
505
+ });
506
+
507
+ onDestroy(() => {
508
+ editor?.destroy();
509
+ document.removeEventListener('click', handleClickOutside);
510
+ document.removeEventListener('keydown', handleKeydown);
511
+ element?.removeEventListener('click', handleAnnotationClick);
512
+ });
513
+
514
+ function handleClickOutside(e: MouseEvent) {
515
+ const target = e.target as HTMLElement;
516
+ if (styleMenuOpen && !target.closest('.richtext-editor')) {
517
+ styleMenuOpen = false;
518
+ }
519
+ if (insertMenuOpen && !target.closest('.richtext-editor')) {
520
+ insertMenuOpen = false;
521
+ }
522
+ }
523
+
524
+ function handleKeydown(e: KeyboardEvent) {
525
+ if (e.key === 'Escape' && expanded) {
526
+ expanded = false;
527
+ }
528
+ if (e.key === 'k' && (e.metaKey || e.ctrlKey) && editor?.isFocused) {
529
+ e.preventDefault();
530
+ openLinkEdit();
531
+ }
532
+ }
533
+
534
+ // Sync value → editor when value changes externally. Track a version
535
+ // counter so we can tell the difference between "parent echoed our
536
+ // own change back" vs "parent loaded a different document."
537
+ let lastSyncedVersion = 0;
538
+ let editorJustCreated = false;
539
+ $effect(() => {
540
+ const v = value;
541
+ if (!editor) return;
542
+ if (editorJustCreated) {
543
+ editorJustCreated = false;
544
+ return;
545
+ }
546
+ if (internalVersion !== lastSyncedVersion) {
547
+ lastSyncedVersion = internalVersion;
548
+ return;
549
+ }
550
+ // Replace content without polluting undo history
551
+ const tiptapDoc = portableTextToTiptap(v || []);
552
+ const newDoc = editor.state.schema.nodeFromJSON(tiptapDoc);
553
+ const tr = editor.state.tr.replaceWith(0, editor.state.doc.content.size, newDoc.content);
554
+ tr.setMeta('addToHistory', false);
555
+ isSyncingFromParent = true;
556
+ editor.view.dispatch(tr);
557
+ isSyncingFromParent = false;
558
+ });
559
+
560
+ $effect(() => {
561
+ if (!expanded || !element) return;
562
+
563
+ const container = element.closest('[data-document-editor]');
564
+ if (!container) return;
565
+
566
+ const updateBounds = () => {
567
+ const rect = container.getBoundingClientRect();
568
+ containerBounds = {
569
+ top: rect.top,
570
+ left: rect.left,
571
+ width: rect.width,
572
+ height: rect.height
573
+ };
574
+ };
575
+
576
+ updateBounds();
577
+ const observer = new ResizeObserver(updateBounds);
578
+ observer.observe(container);
579
+
580
+ return () => {
581
+ observer.disconnect();
582
+ containerBounds = null;
583
+ };
584
+ });
585
+
586
+ let linkMode = $state<'closed' | 'preview' | 'edit'>('closed');
587
+ let linkUrl = $state('');
588
+ let linkInputRef = $state<HTMLInputElement>();
589
+ let linkPopoverPos = $state({ top: 0, left: 0 });
590
+ let linkPreviewHref = $state('');
591
+
592
+ function positionLinkPopover() {
593
+ if (!editor || !element) return;
594
+ const { from, to } = editor.state.selection;
595
+ const start = editor.view.coordsAtPos(from);
596
+ const end = editor.view.coordsAtPos(to);
597
+ const editorRect = element.getBoundingClientRect();
598
+ linkPopoverPos = {
599
+ top: end.bottom - editorRect.top + 4,
600
+ left: Math.max(0, Math.min(start.left, end.left) - editorRect.left)
601
+ };
602
+ }
603
+
604
+ function openLinkEdit() {
605
+ if (!editor || !element) return;
606
+ if (editor.isActive('link')) {
607
+ linkUrl = editor.getAttributes('link').href || '';
608
+ } else {
609
+ linkUrl = '';
610
+ }
611
+ positionLinkPopover();
612
+ linkMode = 'edit';
613
+ queueMicrotask(() => linkInputRef?.focus());
614
+ }
615
+
616
+ function showLinkPreview() {
617
+ if (!editor || !element) return;
618
+ const attrs = editor.getAttributes('link');
619
+ if (!attrs.href) return;
620
+ linkPreviewHref = attrs.href;
621
+ positionLinkPopover();
622
+ linkMode = 'preview';
623
+ }
624
+
625
+ function applyLink() {
626
+ if (!editor) return;
627
+ if (linkUrl.trim()) {
628
+ let href = linkUrl.trim();
629
+ if (
630
+ !/^https?:\/\//.test(href) &&
631
+ !href.startsWith('/') &&
632
+ !href.startsWith('#') &&
633
+ !href.startsWith('mailto:')
634
+ ) {
635
+ href = 'https://' + href;
636
+ }
637
+ editor.chain().focus().setLink({ href }).run();
638
+ } else {
639
+ editor.chain().focus().unsetLink().run();
640
+ }
641
+ linkMode = 'closed';
642
+ linkUrl = '';
643
+ }
644
+
645
+ function removeLink() {
646
+ if (!editor) return;
647
+ editor.chain().focus().unsetLink().run();
648
+ linkMode = 'closed';
649
+ linkUrl = '';
650
+ }
651
+
652
+ function closeLinkPopover() {
653
+ linkMode = 'closed';
654
+ editor?.chain().focus().run();
655
+ }
656
+
657
+ function handleLinkKeydown(e: KeyboardEvent) {
658
+ if (e.key === 'Enter') {
659
+ e.preventDefault();
660
+ applyLink();
661
+ } else if (e.key === 'Escape') {
662
+ closeLinkPopover();
663
+ }
664
+ }
665
+
666
+ // Annotation state
667
+ let annotationModalOpen = $state(false);
668
+ let annotationDef = $state<{ name: string; title: string; fields: any[] } | null>(null);
669
+ let annotationValue = $state<Record<string, any>>({});
670
+ let annotationKey = $state<string | null>(null);
671
+ let annotationRange = $state<{ from: number; to: number } | null>(null);
672
+
673
+ function openAnnotation(name: string) {
674
+ if (!editor) return;
675
+ const def = customAnnotations.find((a) => a.name === name);
676
+ if (!def) return;
677
+
678
+ const { from, to } = editor.state.selection;
679
+ const markName = `annotation_${name}`;
680
+ if (editor.isActive(markName)) {
681
+ const attrs = editor.getAttributes(markName);
682
+ annotationValue = { ...(attrs.data || {}) };
683
+ annotationKey = attrs._key || genKey();
684
+ } else {
685
+ annotationValue = {};
686
+ annotationKey = genKey();
687
+ }
688
+ annotationRange = { from, to };
689
+ annotationDef = def;
690
+ annotationModalOpen = true;
691
+ }
692
+
693
+ function findMarkRange(key: string, markType: any): { from: number; to: number } | null {
694
+ if (!editor) return null;
695
+ let markFrom = -1;
696
+ let markTo = -1;
697
+ editor.state.doc.descendants((node, pos) => {
698
+ const mark = node.marks.find((m: any) => m.type === markType && m.attrs._key === key);
699
+ if (mark) {
700
+ if (markFrom === -1) markFrom = pos;
701
+ markTo = pos + node.nodeSize;
702
+ } else if (markFrom >= 0) {
703
+ return false;
704
+ }
705
+ });
706
+ return markFrom >= 0 ? { from: markFrom, to: markTo } : null;
707
+ }
708
+
709
+ function applyAnnotationMark() {
710
+ if (!editor || !annotationDef || !annotationKey) return;
711
+ const markName = `annotation_${annotationDef.name}`;
712
+ const markType = editor.schema.marks[markName];
713
+ if (!markType) return;
714
+
715
+ const docRange = findMarkRange(annotationKey, markType);
716
+ const range = docRange || annotationRange;
717
+ if (!range || range.from === range.to) return;
718
+
719
+ const tr = editor.state.tr.addMark(
720
+ range.from,
721
+ range.to,
722
+ markType.create({ _key: annotationKey, data: annotationValue })
723
+ );
724
+ editor.view.dispatch(tr);
725
+
726
+ internalVersion++;
727
+ const pt = tiptapToPortableText(editor.getJSON());
728
+ onUpdate(pt);
729
+ }
730
+
731
+ function handleAnnotationUpdate(updatedData: Record<string, any>) {
732
+ annotationValue = updatedData;
733
+ applyAnnotationMark();
734
+ }
735
+
736
+ function handleAnnotationClose() {
737
+ applyAnnotationMark();
738
+ editor?.chain().focus().run();
739
+ annotationModalOpen = false;
740
+ annotationDef = null;
741
+ annotationValue = {};
742
+ annotationKey = null;
743
+ annotationRange = null;
744
+ }
745
+ </script>
746
+
747
+ {#snippet toolbarBtn(
748
+ onclick: () => void,
749
+ label: string,
750
+ isActive: () => boolean,
751
+ icon: any,
752
+ isDisabled?: () => boolean
753
+ )}
754
+ {@const dis = editorState >= 0 && isDisabled?.() === true}
755
+ <Tooltip.Root delayDuration={400}>
756
+ <Tooltip.Trigger
757
+ type="button"
758
+ disabled={dis}
759
+ class="rounded p-1.5 transition-colors {dis
760
+ ? 'text-muted-foreground/40 cursor-not-allowed'
761
+ : editorState >= 0 && isActive()
762
+ ? 'bg-muted text-foreground'
763
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
764
+ onmousedown={(e: MouseEvent) => {
765
+ e.preventDefault();
766
+ if (!dis) onclick();
767
+ }}
768
+ >
769
+ {@const Icon = icon}
770
+ <Icon class="h-4 w-4" />
771
+ </Tooltip.Trigger>
772
+ <Tooltip.Content side="bottom" sideOffset={4}>{label}</Tooltip.Content>
773
+ </Tooltip.Root>
774
+ {/snippet}
775
+
776
+ <div
777
+ class="richtext-editor {validationClasses}"
778
+ class:richtext-expanded={expanded}
779
+ style={expanded && containerBounds
780
+ ? `position: fixed; top: ${containerBounds.top}px; left: ${containerBounds.left}px; width: ${containerBounds.width}px; height: ${containerBounds.height}px; z-index: 50;`
781
+ : ''}
782
+ >
783
+ {#if editor && !readonly}
784
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
785
+ <Tooltip.Provider>
786
+ <div
787
+ class="border-rule bg-muted/30 flex flex-wrap items-center gap-0.5 border-b px-2 py-1.5"
788
+ onmousedown={(e) => e.preventDefault()}
789
+ >
790
+ {#if styles.length > 1}
791
+ <div class="relative">
792
+ <button
793
+ type="button"
794
+ class="text-muted-foreground hover:bg-muted hover:text-foreground flex items-center gap-1 rounded px-2 py-1 text-xs font-medium transition-colors"
795
+ onclick={() => (styleMenuOpen = !styleMenuOpen)}
796
+ >
797
+ <span>{styleLabels[activeStyle] ?? 'Normal'}</span>
798
+ <ChevronDown class="h-3 w-3" />
799
+ </button>
800
+ {#if styleMenuOpen}
801
+ <div
802
+ class="bg-popover border-rule absolute top-full left-0 z-40 mt-1 min-w-[140px] rounded-md border py-1 shadow-lg"
803
+ >
804
+ {#each styles as s}
805
+ <button
806
+ type="button"
807
+ class="flex w-full items-center px-3 py-1.5 text-left text-sm transition-colors {activeStyle ===
808
+ s
809
+ ? 'bg-muted text-foreground font-medium'
810
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
811
+ onmousedown={(e) => {
812
+ e.preventDefault();
813
+ applyStyle(s);
814
+ }}
815
+ >
816
+ {styleLabels[s] ?? s}
817
+ </button>
818
+ {/each}
819
+ </div>
820
+ {/if}
821
+ </div>
822
+ <div class="bg-border mx-1 h-4 w-px"></div>
823
+ {/if}
824
+
825
+ {#if hasDecorator('strong')}
826
+ {@render toolbarBtn(
827
+ () => editor?.chain().focus().toggleBold().run(),
828
+ 'Bold',
829
+ () => editor?.isActive('bold') ?? false,
830
+ Bold
831
+ )}
832
+ {/if}
833
+ {#if hasDecorator('em')}
834
+ {@render toolbarBtn(
835
+ () => editor?.chain().focus().toggleItalic().run(),
836
+ 'Italic',
837
+ () => editor?.isActive('italic') ?? false,
838
+ Italic
839
+ )}
840
+ {/if}
841
+ {#if hasDecorator('underline')}
842
+ {@render toolbarBtn(
843
+ () => editor?.chain().focus().toggleUnderline().run(),
844
+ 'Underline',
845
+ () => editor?.isActive('underline') ?? false,
846
+ UnderlineIcon
847
+ )}
848
+ {/if}
849
+ {#if hasDecorator('strike-through')}
850
+ {@render toolbarBtn(
851
+ () => editor?.chain().focus().toggleStrike().run(),
852
+ 'Strikethrough',
853
+ () => editor?.isActive('strike') ?? false,
854
+ Strikethrough
855
+ )}
856
+ {/if}
857
+ {@render toolbarBtn(
858
+ () => editor?.chain().focus().toggleCodeBlock().run(),
859
+ 'Code block',
860
+ () => editor?.isActive('codeBlock') ?? false,
861
+ Code
862
+ )}
863
+
864
+ {#if (hasDecorator('strong') || hasDecorator('em')) && (hasList('bullet') || hasList('number') || hasLink)}
865
+ <div class="bg-border mx-1 h-4 w-px"></div>
866
+ {/if}
867
+
868
+ {#if hasList('bullet')}
869
+ {@render toolbarBtn(
870
+ () => editor?.chain().focus().toggleBulletList().run(),
871
+ 'Bullet list',
872
+ () => editor?.isActive('bulletList') ?? false,
873
+ List
874
+ )}
875
+ {/if}
876
+ {#if hasList('number')}
877
+ {@render toolbarBtn(
878
+ () => editor?.chain().focus().toggleOrderedList().run(),
879
+ 'Numbered list',
880
+ () => editor?.isActive('orderedList') ?? false,
881
+ ListOrdered
882
+ )}
883
+ {/if}
884
+
885
+ {#if hasLink}
886
+ {#if hasList('bullet') || hasList('number')}
887
+ <div class="bg-border mx-1 h-4 w-px"></div>
888
+ {/if}
889
+ {@render toolbarBtn(
890
+ openLinkEdit,
891
+ (editor?.isActive('link') ?? false) ? 'Edit link' : 'Add link',
892
+ () => (editor?.isActive('link') ?? false) || linkMode !== 'closed',
893
+ LinkIcon
894
+ )}
895
+ {/if}
896
+
897
+ {#if hasImageBlock}
898
+ <div class="bg-border mx-1 h-4 w-px"></div>
899
+ {@render toolbarBtn(handleInsertImageBlock, 'Insert image', () => false, ImageIcon)}
900
+ {/if}
901
+
902
+ {#if hasCustomTypes || hasInlineTypes}
903
+ <div class="bg-border mx-1 h-4 w-px"></div>
904
+ <div class="relative">
905
+ <Tooltip.Root delayDuration={400}>
906
+ <Tooltip.Trigger
907
+ type="button"
908
+ class="text-muted-foreground hover:bg-muted hover:text-foreground rounded p-1.5 transition-colors"
909
+ onmousedown={(e: MouseEvent) => {
910
+ e.preventDefault();
911
+ insertMenuOpen = !insertMenuOpen;
912
+ }}
913
+ >
914
+ <Plus class="h-4 w-4" />
915
+ </Tooltip.Trigger>
916
+ <Tooltip.Content side="bottom" sideOffset={4}>Insert block</Tooltip.Content>
917
+ </Tooltip.Root>
918
+ {#if insertMenuOpen}
919
+ <div
920
+ class="bg-popover border-rule absolute top-full left-0 z-40 mt-1 min-w-[160px] rounded-md border py-1 shadow-lg"
921
+ >
922
+ {#if hasCustomTypes}
923
+ <div class="text-muted-foreground px-3 py-1 text-[10px] font-medium uppercase">
924
+ Blocks
925
+ </div>
926
+ {#each customTypes as ct}
927
+ <button
928
+ type="button"
929
+ class="hover:bg-muted flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors"
930
+ onclick={() => handleInsertBlock(ct.type)}
931
+ >
932
+ <span
933
+ class="bg-muted text-muted-foreground flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-medium uppercase"
934
+ >{ct.type.slice(0, 2)}</span
935
+ >
936
+ <span class="capitalize">{ct.title}</span>
937
+ </button>
938
+ {/each}
939
+ {/if}
940
+ {#if hasInlineTypes}
941
+ {#if hasCustomTypes}
942
+ <div class="bg-border mx-2 my-1 h-px"></div>
943
+ {/if}
944
+ <div class="text-muted-foreground px-3 py-1 text-[10px] font-medium uppercase">
945
+ Inline
946
+ </div>
947
+ {#each inlineTypes as it}
948
+ <button
949
+ type="button"
950
+ class="hover:bg-muted flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors"
951
+ onclick={() => handleInsertInline(it.type)}
952
+ >
953
+ <span
954
+ class="bg-primary/20 text-primary flex h-5 w-5 shrink-0 items-center justify-center rounded text-[10px] font-medium uppercase"
955
+ >{it.type.slice(0, 2)}</span
956
+ >
957
+ <span class="capitalize">{it.title}</span>
958
+ </button>
959
+ {/each}
960
+ {/if}
961
+ </div>
962
+ {/if}
963
+ </div>
964
+ {/if}
965
+
966
+ {#if hasAnnotations}
967
+ <div class="bg-border mx-1 h-4 w-px"></div>
968
+ {#each customAnnotations as ann}
969
+ <Tooltip.Root delayDuration={400}>
970
+ <Tooltip.Trigger
971
+ type="button"
972
+ class="rounded p-1.5 text-xs font-medium transition-colors {editor.isActive(
973
+ `annotation_${ann.name}`
974
+ )
975
+ ? 'bg-muted text-foreground'
976
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground'}"
977
+ onmousedown={(e: MouseEvent) => {
978
+ e.preventDefault();
979
+ openAnnotation(ann.name);
980
+ }}
981
+ >
982
+ {ann.title.slice(0, 2).toUpperCase()}
983
+ </Tooltip.Trigger>
984
+ <Tooltip.Content side="bottom" sideOffset={4}>{ann.title}</Tooltip.Content>
985
+ </Tooltip.Root>
986
+ {/each}
987
+ {/if}
988
+
989
+ <div class="flex-1"></div>
990
+
991
+ {@render toolbarBtn(
992
+ () => editor?.chain().focus().undo().run(),
993
+ 'Undo',
994
+ () => false,
995
+ Undo2,
996
+ () => !(editor?.can().undo() ?? false)
997
+ )}
998
+ {@render toolbarBtn(
999
+ () => editor?.chain().focus().redo().run(),
1000
+ 'Redo',
1001
+ () => false,
1002
+ Redo2,
1003
+ () => !(editor?.can().redo() ?? false)
1004
+ )}
1005
+
1006
+ <div class="bg-border mx-1 h-4 w-px"></div>
1007
+ {@render toolbarBtn(
1008
+ () => (expanded = !expanded),
1009
+ expanded ? 'Collapse' : 'Expand',
1010
+ () => expanded,
1011
+ expanded ? Minimize2 : Maximize2
1012
+ )}
1013
+ </div>
1014
+ </Tooltip.Provider>
1015
+ {/if}
1016
+
1017
+ <div bind:this={element} class="richtext-content"></div>
1018
+
1019
+ {#if linkMode === 'preview'}
1020
+ <div
1021
+ class="border-rule bg-popover absolute z-40 flex items-center gap-1.5 rounded-md border px-2 py-1.5 shadow-lg"
1022
+ style="top: {linkPopoverPos.top}px; left: {linkPopoverPos.left}px;"
1023
+ >
1024
+ <a
1025
+ href={linkPreviewHref}
1026
+ target="_blank"
1027
+ rel="noopener noreferrer"
1028
+ class="text-primary max-w-[200px] truncate text-xs underline"
1029
+ title={linkPreviewHref}
1030
+ >
1031
+ {linkPreviewHref.replace(/^https?:\/\//, '')}
1032
+ </a>
1033
+ <div class="bg-border mx-0.5 h-3 w-px"></div>
1034
+ <button
1035
+ type="button"
1036
+ class="text-muted-foreground hover:text-foreground rounded p-0.5 transition-colors"
1037
+ onclick={() => {
1038
+ navigator.clipboard.writeText(linkPreviewHref);
1039
+ }}
1040
+ title="Copy URL"
1041
+ >
1042
+ <Copy class="h-3.5 w-3.5" />
1043
+ </button>
1044
+ <button
1045
+ type="button"
1046
+ class="text-muted-foreground hover:text-foreground rounded p-0.5 transition-colors"
1047
+ onclick={openLinkEdit}
1048
+ title="Edit link"
1049
+ >
1050
+ <Pencil class="h-3.5 w-3.5" />
1051
+ </button>
1052
+ <button
1053
+ type="button"
1054
+ class="text-muted-foreground hover:text-destructive rounded p-0.5 transition-colors"
1055
+ onclick={removeLink}
1056
+ title="Remove link"
1057
+ >
1058
+ <Unlink class="h-3.5 w-3.5" />
1059
+ </button>
1060
+ </div>
1061
+ {:else if linkMode === 'edit'}
1062
+ <div
1063
+ class="border-rule bg-popover absolute z-40 flex items-center gap-2 rounded-md border px-3 py-2 shadow-lg"
1064
+ style="top: {linkPopoverPos.top}px; left: {linkPopoverPos.left}px;"
1065
+ >
1066
+ <LinkIcon class="text-muted-foreground h-4 w-4 shrink-0" />
1067
+ <input
1068
+ bind:this={linkInputRef}
1069
+ type="text"
1070
+ class="text-foreground placeholder:text-muted-foreground w-52 bg-transparent text-sm outline-none"
1071
+ placeholder="example.com"
1072
+ bind:value={linkUrl}
1073
+ onkeydown={handleLinkKeydown}
1074
+ />
1075
+ <button
1076
+ type="button"
1077
+ class="bg-primary text-primary-foreground rounded px-2.5 py-1 text-xs font-medium whitespace-nowrap transition-colors"
1078
+ onclick={applyLink}
1079
+ >
1080
+ {editor?.isActive('link') ? 'Update' : 'Apply'}
1081
+ </button>
1082
+ {#if editor?.isActive('link')}
1083
+ <button
1084
+ type="button"
1085
+ class="text-muted-foreground hover:text-destructive rounded p-1 transition-colors"
1086
+ onclick={removeLink}
1087
+ title="Remove link"
1088
+ >
1089
+ <Unlink class="h-3.5 w-3.5" />
1090
+ </button>
1091
+ {/if}
1092
+ <button
1093
+ type="button"
1094
+ class="text-muted-foreground hover:text-foreground rounded p-1 transition-colors"
1095
+ onclick={closeLinkPopover}
1096
+ title="Cancel"
1097
+ >
1098
+ <X class="h-3.5 w-3.5" />
1099
+ </button>
1100
+ </div>
1101
+ {/if}
1102
+ </div>
1103
+
1104
+ {#if modalOpen && editingSchema}
1105
+ <ObjectModal
1106
+ open={modalOpen}
1107
+ schema={editingSchema}
1108
+ value={editingValue}
1109
+ onClose={handleModalClose}
1110
+ onUpdate={handleModalUpdate}
1111
+ {onOpenReference}
1112
+ {readonly}
1113
+ {organizationId}
1114
+ />
1115
+ {/if}
1116
+
1117
+ {#if annotationModalOpen && annotationDef}
1118
+ <ObjectModal
1119
+ open={annotationModalOpen}
1120
+ schema={{
1121
+ type: 'object',
1122
+ name: annotationDef.name,
1123
+ title: annotationDef.title,
1124
+ fields: annotationDef.fields
1125
+ }}
1126
+ value={annotationValue}
1127
+ onClose={handleAnnotationClose}
1128
+ onUpdate={handleAnnotationUpdate}
1129
+ {onOpenReference}
1130
+ {readonly}
1131
+ {organizationId}
1132
+ />
1133
+ {/if}
1134
+
1135
+ <AssetBrowserModal
1136
+ bind:open={showAssetBrowser}
1137
+ onOpenChange={(v) => (showAssetBrowser = v)}
1138
+ assetTypeFilter="image"
1139
+ onSelect={handleAssetSelected}
1140
+ />
1141
+
1142
+ <style>
1143
+ .richtext-editor {
1144
+ position: relative;
1145
+ border: 1px solid var(--border);
1146
+ border-radius: var(--radius);
1147
+ overflow: visible;
1148
+ display: flex;
1149
+ flex-direction: column;
1150
+ min-height: 0;
1151
+ }
1152
+
1153
+ .richtext-expanded {
1154
+ border: none;
1155
+ border-radius: 0;
1156
+ background: var(--background);
1157
+ }
1158
+
1159
+ .richtext-content {
1160
+ flex: 1;
1161
+ display: flex;
1162
+ flex-direction: column;
1163
+ min-height: 0;
1164
+ overflow: auto;
1165
+ }
1166
+
1167
+ .richtext-content :global(.tiptap) {
1168
+ padding: 0.75rem 1rem;
1169
+ min-height: 150px;
1170
+ flex: 1;
1171
+ outline: none;
1172
+ font-size: 1rem;
1173
+ line-height: 1.7;
1174
+ }
1175
+
1176
+ .richtext-content :global(.tiptap p) {
1177
+ margin: 0.5em 0;
1178
+ }
1179
+
1180
+ .richtext-content :global(.tiptap p:first-child) {
1181
+ margin-top: 0;
1182
+ }
1183
+
1184
+ .richtext-content :global(.tiptap p:last-child) {
1185
+ margin-bottom: 0;
1186
+ }
1187
+
1188
+ .richtext-content :global(.tiptap h1) {
1189
+ font-size: 1.875rem;
1190
+ font-weight: 700;
1191
+ margin: 0.75em 0 0.25em;
1192
+ line-height: 1.2;
1193
+ }
1194
+
1195
+ .richtext-content :global(.tiptap h2) {
1196
+ font-size: 1.5rem;
1197
+ font-weight: 600;
1198
+ margin: 0.75em 0 0.25em;
1199
+ line-height: 1.3;
1200
+ }
1201
+
1202
+ .richtext-content :global(.tiptap h3) {
1203
+ font-size: 1.25rem;
1204
+ font-weight: 600;
1205
+ margin: 0.75em 0 0.25em;
1206
+ line-height: 1.4;
1207
+ }
1208
+
1209
+ .richtext-content :global(.tiptap h4) {
1210
+ font-size: 1.125rem;
1211
+ font-weight: 600;
1212
+ margin: 0.75em 0 0.25em;
1213
+ line-height: 1.4;
1214
+ }
1215
+
1216
+ .richtext-content :global(.tiptap h5) {
1217
+ font-size: 1rem;
1218
+ font-weight: 600;
1219
+ margin: 0.75em 0 0.25em;
1220
+ line-height: 1.5;
1221
+ }
1222
+
1223
+ .richtext-content :global(.tiptap h6) {
1224
+ font-size: 0.875rem;
1225
+ font-weight: 600;
1226
+ margin: 0.75em 0 0.25em;
1227
+ line-height: 1.5;
1228
+ text-transform: uppercase;
1229
+ letter-spacing: 0.05em;
1230
+ color: var(--muted-foreground);
1231
+ }
1232
+
1233
+ .richtext-content :global(.tiptap blockquote) {
1234
+ border-left: 3px solid var(--border);
1235
+ padding-left: 1rem;
1236
+ margin: 0.75em 0;
1237
+ color: var(--muted-foreground);
1238
+ }
1239
+
1240
+ .richtext-content :global(.tiptap ul) {
1241
+ list-style: disc;
1242
+ padding-left: 1.5rem;
1243
+ margin: 0.5em 0;
1244
+ }
1245
+
1246
+ .richtext-content :global(.tiptap ol) {
1247
+ list-style: decimal;
1248
+ padding-left: 1.5rem;
1249
+ margin: 0.5em 0;
1250
+ }
1251
+
1252
+ .richtext-content :global(.tiptap li) {
1253
+ margin: 0.25em 0;
1254
+ }
1255
+
1256
+ .richtext-content :global(.tiptap pre) {
1257
+ background: var(--muted);
1258
+ border-radius: 6px;
1259
+ padding: 0.75rem 1rem;
1260
+ margin: 0.75em 0;
1261
+ overflow-x: auto;
1262
+ }
1263
+
1264
+ .richtext-content :global(.tiptap pre code) {
1265
+ background: none;
1266
+ padding: 0;
1267
+ border-radius: 0;
1268
+ font-size: 0.875em;
1269
+ font-family: var(--font-mono, monospace);
1270
+ }
1271
+
1272
+ .richtext-content :global(.tiptap code) {
1273
+ background: var(--muted);
1274
+ border-radius: 3px;
1275
+ padding: 0.15em 0.3em;
1276
+ font-size: 0.85em;
1277
+ font-family: var(--font-mono, monospace);
1278
+ }
1279
+
1280
+ .richtext-content :global(.tiptap a),
1281
+ .richtext-content :global(.tiptap .richtext-link) {
1282
+ color: var(--primary);
1283
+ text-decoration: underline;
1284
+ cursor: pointer;
1285
+ }
1286
+
1287
+ .richtext-content :global(.tiptap strong) {
1288
+ font-weight: 600;
1289
+ }
1290
+
1291
+ .richtext-content :global(.tiptap em) {
1292
+ font-style: italic;
1293
+ }
1294
+
1295
+ .richtext-content :global(.tiptap s) {
1296
+ text-decoration: line-through;
1297
+ }
1298
+
1299
+ .richtext-content :global(.tiptap u) {
1300
+ text-decoration: underline;
1301
+ }
1302
+
1303
+ .richtext-content :global(.tiptap [data-portable-text-object]) {
1304
+ margin: 0.5em 0;
1305
+ }
1306
+
1307
+ .richtext-content :global(.tiptap .ProseMirror-selectednode [data-portable-text-object] > div) {
1308
+ outline: 2px solid var(--primary);
1309
+ }
1310
+
1311
+ .richtext-content :global(.tiptap [data-portable-text-inline]) {
1312
+ display: inline;
1313
+ }
1314
+ </style>