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