@jsonpages/cli 3.0.61 → 3.0.63

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.
@@ -39,22 +39,21 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
39
39
  "preview": "vite preview"
40
40
  },
41
41
  "dependencies": {
42
- "@tiptap/extension-image": "latest",
43
- "@tiptap/extension-link": "latest",
44
- "@tiptap/extension-underline": "latest",
45
- "@tiptap/react": "latest",
46
- "@tiptap/starter-kit": "latest",
47
- "@jsonpages/core": "^1.0.48",
42
+ "@tiptap/extension-image": "^2.11.5",
43
+ "@tiptap/extension-link": "^2.11.5",
44
+ "@tiptap/react": "^2.11.5",
45
+ "@tiptap/starter-kit": "^2.11.5",
46
+ "@jsonpages/core": "^1.0.50",
48
47
  "clsx": "^2.1.1",
49
48
  "lucide-react": "^0.474.0",
50
49
  "react": "^19.0.0",
51
- "react-markdown": "latest",
50
+ "react-markdown": "^9.0.1",
52
51
  "react-dom": "^19.0.0",
53
52
  "react-router-dom": "^6.30.3",
54
- "rehype-sanitize": "latest",
55
- "remark-gfm": "latest",
53
+ "rehype-sanitize": "^6.0.0",
54
+ "remark-gfm": "^4.0.1",
56
55
  "tailwind-merge": "^3.0.1",
57
- "tiptap-markdown": "latest",
56
+ "tiptap-markdown": "^0.8.10",
58
57
  "zod": "^3.24.1"
59
58
  },
60
59
  "devDependencies": {
@@ -3108,30 +3107,20 @@ import type { Editor } from '@tiptap/react';
3108
3107
  import StarterKit from '@tiptap/starter-kit';
3109
3108
  import Link from '@tiptap/extension-link';
3110
3109
  import Image from '@tiptap/extension-image';
3111
- import Underline from '@tiptap/extension-underline';
3112
3110
  import { Markdown } from 'tiptap-markdown';
3113
3111
  import {
3114
- Undo2,
3115
- Redo2,
3116
- Heading,
3117
- List,
3118
- ListOrdered,
3119
- Bold,
3120
- Italic,
3121
- Strikethrough,
3122
- Underline as UnderlineIcon,
3123
- Code2,
3124
- Quote,
3125
- SquareCode,
3126
- Link2,
3127
- Unlink2,
3128
- ImagePlus,
3129
- Eraser,
3112
+ Undo2, Redo2,
3113
+ List, ListOrdered,
3114
+ Bold, Italic, Strikethrough,
3115
+ Code2, Quote, SquareCode,
3116
+ Link2, Unlink2, ImagePlus, Eraser,
3130
3117
  } from 'lucide-react';
3131
3118
  import { STUDIO_EVENTS, useConfig, useStudio } from '@jsonpages/core';
3132
3119
  import type { TiptapData, TiptapSettings } from './types';
3133
3120
 
3134
- const ToolbarButton: React.FC<{
3121
+ // ── UI primitives ─────────────────────────────────────────────────────────────
3122
+
3123
+ const Btn: React.FC<{
3135
3124
  active?: boolean;
3136
3125
  title: string;
3137
3126
  onClick: () => void;
@@ -3143,171 +3132,196 @@ const ToolbarButton: React.FC<{
3143
3132
  onMouseDown={(e) => e.preventDefault()}
3144
3133
  onClick={onClick}
3145
3134
  className={[
3146
- 'inline-flex h-7 min-w-7 items-center justify-center rounded-md border px-2 text-xs transition-colors',
3135
+ 'inline-flex h-7 min-w-7 items-center justify-center rounded-md px-2 text-xs transition-colors',
3147
3136
  active
3148
- ? 'bg-blue-600/25 border-blue-400/60 text-blue-100'
3149
- : 'bg-zinc-900 border-zinc-700 text-zinc-200 hover:bg-zinc-800',
3137
+ ? 'bg-zinc-700/70 text-zinc-100'
3138
+ : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200',
3150
3139
  ].join(' ')}
3151
3140
  >
3152
3141
  {children}
3153
3142
  </button>
3154
3143
  );
3155
3144
 
3156
- const ToolbarSeparator: React.FC = () => <span className="mx-1 h-5 w-px bg-zinc-700" aria-hidden />;
3145
+ const Sep: React.FC = () => (
3146
+ <span className="mx-0.5 h-5 w-px shrink-0 bg-zinc-800" aria-hidden />
3147
+ );
3148
+
3149
+ // ── Image extension with upload metadata ──────────────────────────────────────
3157
3150
 
3158
3151
  const UploadableImage = Image.extend({
3159
3152
  addAttributes() {
3153
+ const bool = (attr: string) => ({
3154
+ default: false,
3155
+ parseHTML: (el: HTMLElement) => el.getAttribute(attr) === 'true',
3156
+ renderHTML: (attrs: Record<string, unknown>) =>
3157
+ attrs[attr.replace('data-', '').replace(/-([a-z])/g, (_, c: string) => c.toUpperCase())]
3158
+ ? { [attr]: 'true' }
3159
+ : {},
3160
+ });
3160
3161
  return {
3161
3162
  ...this.parent?.(),
3162
3163
  uploadId: {
3163
3164
  default: null,
3164
- parseHTML: (element: HTMLElement) => element.getAttribute('data-upload-id'),
3165
- renderHTML: (attributes: Record<string, unknown>) =>
3166
- attributes.uploadId ? { 'data-upload-id': String(attributes.uploadId) } : {},
3167
- },
3168
- uploading: {
3169
- default: false,
3170
- parseHTML: (element: HTMLElement) => element.getAttribute('data-uploading') === 'true',
3171
- renderHTML: (attributes: Record<string, unknown>) =>
3172
- attributes.uploading ? { 'data-uploading': 'true' } : {},
3173
- },
3174
- uploadError: {
3175
- default: false,
3176
- parseHTML: (element: HTMLElement) => element.getAttribute('data-upload-error') === 'true',
3177
- renderHTML: (attributes: Record<string, unknown>) =>
3178
- attributes.uploadError ? { 'data-upload-error': 'true' } : {},
3179
- },
3180
- awaitingUpload: {
3181
- default: false,
3182
- parseHTML: (element: HTMLElement) => element.getAttribute('data-awaiting-upload') === 'true',
3183
- renderHTML: (attributes: Record<string, unknown>) =>
3184
- attributes.awaitingUpload ? { 'data-awaiting-upload': 'true' } : {},
3165
+ parseHTML: (el: HTMLElement) => el.getAttribute('data-upload-id'),
3166
+ renderHTML: (attrs: Record<string, unknown>) =>
3167
+ attrs.uploadId ? { 'data-upload-id': String(attrs.uploadId) } : {},
3185
3168
  },
3169
+ uploading: bool('data-uploading'),
3170
+ uploadError: bool('data-upload-error'),
3171
+ awaitingUpload: bool('data-awaiting-upload'),
3186
3172
  };
3187
3173
  },
3188
3174
  });
3189
3175
 
3190
- const getEditorMarkdown = (editor: Editor | null | undefined): string => {
3191
- const storage = editor?.storage as
3192
- | {
3193
- markdown?: {
3194
- getMarkdown?: () => string;
3195
- };
3196
- }
3197
- | undefined;
3198
- return storage?.markdown?.getMarkdown?.() ?? '';
3199
- };
3176
+ // ── Helpers ───────────────────────────────────────────────────────────────────
3200
3177
 
3201
- const UPLOAD_PLACEHOLDER_SRC =
3202
- "data:image/svg+xml;utf8," +
3203
- encodeURIComponent(
3204
- `<svg xmlns='http://www.w3.org/2000/svg' width='1200' height='420' viewBox='0 0 1200 420'>
3205
- <rect width='1200' height='420' fill='#090B14' stroke='#3F3F46' stroke-width='3' stroke-dasharray='10 10' rx='12' />
3206
- <g font-family='Inter,Arial,sans-serif' text-anchor='middle' fill='#A1A1AA'>
3207
- <text x='600' y='190' font-size='30' font-weight='700'>Uploading image...</text>
3208
- <text x='600' y='235' font-size='22'>Please wait while we process your file</text>
3209
- </g>
3210
- </svg>`
3211
- );
3178
+ const getMarkdown = (ed: Editor | null | undefined): string =>
3179
+ (ed?.storage as { markdown?: { getMarkdown?: () => string } } | undefined)
3180
+ ?.markdown?.getMarkdown?.() ?? '';
3212
3181
 
3213
- const UPLOAD_PICKER_PLACEHOLDER_SRC =
3214
- "data:image/svg+xml;utf8," +
3182
+ const svg = (body: string) =>
3183
+ 'data:image/svg+xml;utf8,' +
3215
3184
  encodeURIComponent(
3216
- `<svg xmlns='http://www.w3.org/2000/svg' width='1200' height='420' viewBox='0 0 1200 420'>
3217
- <rect width='1200' height='420' fill='#090B14' stroke='#3F3F46' stroke-width='3' stroke-dasharray='10 10' rx='12' />
3218
- <g font-family='Inter,Arial,sans-serif' text-anchor='middle'>
3219
- <text x='600' y='210' font-size='34' font-weight='700' fill='#E4E4E7'>Click to upload or drag and drop</text>
3220
- <text x='600' y='255' font-size='24' font-weight='500' fill='#A1A1AA'>Maximum 3 files, 5MB each.</text>
3221
- </g>
3222
- </svg>`
3185
+ `<svg xmlns='http://www.w3.org/2000/svg' width='1200' height='420' viewBox='0 0 1200 420'>${body}</svg>`
3223
3186
  );
3224
3187
 
3225
- const updateImageByUploadId = (
3226
- editor: Editor,
3188
+ const RECT = `<rect width='1200' height='420' fill='#090B14' stroke='#3F3F46' stroke-width='3' stroke-dasharray='10 10' rx='12'/>`;
3189
+
3190
+ const UPLOADING_SRC = svg(
3191
+ RECT +
3192
+ `<text x='600' y='215' font-family='Inter,Arial,sans-serif' font-size='28' font-weight='700' fill='#A1A1AA' text-anchor='middle'>Uploading image…</text>`
3193
+ );
3194
+
3195
+ const PICKER_SRC = svg(
3196
+ RECT +
3197
+ `<text x='600' y='200' font-family='Inter,Arial,sans-serif' font-size='32' font-weight='700' fill='#E4E4E7' text-anchor='middle'>Click to upload or drag &amp; drop</text>` +
3198
+ `<text x='600' y='248' font-family='Inter,Arial,sans-serif' font-size='22' fill='#A1A1AA' text-anchor='middle'>Max 5 MB per file</text>`
3199
+ );
3200
+
3201
+ const patchImage = (
3202
+ ed: Editor,
3227
3203
  uploadId: string,
3228
3204
  patch: Record<string, unknown>
3229
3205
  ): boolean => {
3230
- let targetPos: number | null = null;
3231
- editor.state.doc.descendants((node: { type: { name: string }; attrs?: Record<string, unknown> }, pos: number) => {
3232
- if (node.type.name === 'image' && node.attrs?.uploadId === uploadId) {
3233
- targetPos = pos;
3234
- return false;
3206
+ let pos: number | null = null;
3207
+ ed.state.doc.descendants(
3208
+ (node: { type: { name: string }; attrs?: Record<string, unknown> }, p: number) => {
3209
+ if (node.type.name === 'image' && node.attrs?.uploadId === uploadId) {
3210
+ pos = p;
3211
+ return false;
3212
+ }
3213
+ return true;
3235
3214
  }
3236
- return true;
3237
- });
3238
- if (targetPos == null) return false;
3239
- const current = editor.state.doc.nodeAt(targetPos);
3240
- if (!current) return false;
3241
- const attrs = { ...current.attrs, ...patch };
3242
- editor.view.dispatch(editor.state.tr.setNodeMarkup(targetPos, undefined, attrs));
3215
+ );
3216
+ if (pos == null) return false;
3217
+ const cur = ed.state.doc.nodeAt(pos);
3218
+ if (!cur) return false;
3219
+ ed.view.dispatch(ed.state.tr.setNodeMarkup(pos, undefined, { ...cur.attrs, ...patch }));
3243
3220
  return true;
3244
3221
  };
3245
3222
 
3223
+ // Extensions defined outside component — stable reference, no re-creation on render
3224
+ const EXTENSIONS = [
3225
+ StarterKit,
3226
+ Link.configure({ openOnClick: false, autolink: true }),
3227
+ UploadableImage,
3228
+ // NOTE: Underline is intentionally excluded.
3229
+ // tiptap-markdown with html:false cannot round-trip <u> tags, so underline
3230
+ // would be silently dropped on save. Use bold/italic instead.
3231
+ Markdown.configure({ html: false }),
3232
+ ];
3233
+
3234
+ const EDITOR_CLASSES =
3235
+ 'min-h-[220px] p-4 outline-none';
3236
+
3237
+ // ── Studio editor component ───────────────────────────────────────────────────
3238
+
3246
3239
  const StudioTiptapEditor: React.FC<{ data: TiptapData }> = ({ data }) => {
3247
3240
  const { assets } = useConfig();
3248
- const rootRef = React.useRef<HTMLElement | null>(null);
3249
- const editorHostRef = React.useRef<HTMLDivElement | null>(null);
3250
- const editorInstanceRef = React.useRef<Editor | null>(null);
3241
+
3242
+ // DOM refs
3243
+ const hostRef = React.useRef<HTMLDivElement | null>(null);
3244
+ const sectionRef = React.useRef<HTMLElement | null>(null);
3251
3245
  const fileInputRef = React.useRef<HTMLInputElement | null>(null);
3252
- const pendingUploadsRef = React.useRef<Map<string, Promise<void>>>(new Map());
3253
- const pendingPickerUploadIdRef = React.useRef<string | null>(null);
3254
- const latestMarkdownRef = React.useRef<string>(data.content ?? '');
3255
- const latestEmittedMarkdownRef = React.useRef<string>(data.content ?? '');
3256
-
3257
- const resolveSectionId = React.useCallback(() => {
3258
- const sectionEl =
3259
- rootRef.current ??
3260
- (editorHostRef.current?.closest('[data-section-id]') as HTMLElement | null);
3261
- rootRef.current = sectionEl;
3262
- return sectionEl?.getAttribute('data-section-id') ?? null;
3246
+
3247
+ // Editor & upload state
3248
+ const editorRef = React.useRef<Editor | null>(null);
3249
+ const pendingUploads = React.useRef<Map<string, Promise<void>>>(new Map());
3250
+ const pendingPickerId = React.useRef<string | null>(null);
3251
+
3252
+ // Markdown sync refs
3253
+ const latestMd = React.useRef<string>(data.content ?? '');
3254
+ const emittedMd = React.useRef<string>(data.content ?? '');
3255
+
3256
+ // Link popover state
3257
+ const [linkOpen, setLinkOpen] = React.useState(false);
3258
+ const [linkUrl, setLinkUrl] = React.useState('');
3259
+ const linkInputRef = React.useRef<HTMLInputElement | null>(null);
3260
+
3261
+ // ── Core helpers ────────────────────────────────────────────────────────
3262
+
3263
+ const getSectionId = React.useCallback((): string | null => {
3264
+ const el =
3265
+ sectionRef.current ??
3266
+ (hostRef.current?.closest('[data-section-id]') as HTMLElement | null);
3267
+ sectionRef.current = el;
3268
+ return el?.getAttribute('data-section-id') ?? null;
3263
3269
  }, []);
3264
3270
 
3265
- const emitInlineUpdate = React.useCallback((markdown: string) => {
3266
- latestMarkdownRef.current = markdown;
3267
- const sectionId = resolveSectionId();
3268
- if (!sectionId) return;
3269
- window.parent.postMessage(
3270
- {
3271
- type: STUDIO_EVENTS.INLINE_FIELD_UPDATE,
3272
- sectionId,
3273
- fieldKey: 'content',
3274
- value: markdown,
3275
- },
3276
- window.location.origin
3277
- );
3278
- latestEmittedMarkdownRef.current = markdown;
3279
- }, [resolveSectionId]);
3271
+ const emit = React.useCallback(
3272
+ (markdown: string) => {
3273
+ latestMd.current = markdown;
3274
+ const sectionId = getSectionId();
3275
+ if (!sectionId) return;
3276
+ window.parent.postMessage(
3277
+ {
3278
+ type: STUDIO_EVENTS.INLINE_FIELD_UPDATE,
3279
+ sectionId,
3280
+ fieldKey: 'content',
3281
+ value: markdown,
3282
+ },
3283
+ window.location.origin
3284
+ );
3285
+ emittedMd.current = markdown;
3286
+ },
3287
+ [getSectionId]
3288
+ );
3289
+
3290
+ const setFocusLock = React.useCallback((on: boolean) => {
3291
+ sectionRef.current?.classList.toggle('jp-editorial-focus', on);
3292
+ }, []);
3293
+
3294
+ // ── Image upload ─────────────────────────────────────────────────────────
3280
3295
 
3281
- const insertUploadPlaceholder = React.useCallback(
3296
+ const insertPlaceholder = React.useCallback(
3282
3297
  (uploadId: string, src: string, awaitingUpload: boolean) => {
3283
- const editor = editorInstanceRef.current;
3284
- if (!editor) return;
3285
- editor
3286
- .chain()
3298
+ const ed = editorRef.current;
3299
+ if (!ed) return;
3300
+ ed.chain()
3287
3301
  .focus()
3288
3302
  .setImage({
3289
3303
  src,
3290
3304
  alt: 'upload-placeholder',
3291
- title: awaitingUpload ? 'Click to upload' : 'Uploading...',
3305
+ title: awaitingUpload ? 'Click to upload' : 'Uploading…',
3292
3306
  uploadId,
3293
3307
  uploading: !awaitingUpload,
3294
3308
  awaitingUpload,
3295
3309
  uploadError: false,
3296
3310
  } as any)
3297
3311
  .run();
3298
- emitInlineUpdate(getEditorMarkdown(editor));
3312
+ emit(getMarkdown(ed));
3299
3313
  },
3300
- [emitInlineUpdate]
3314
+ [emit]
3301
3315
  );
3302
3316
 
3303
- const startUploadForId = React.useCallback(
3317
+ const doUpload = React.useCallback(
3304
3318
  async (uploadId: string, file: File) => {
3305
- const uploadAsset = assets?.onAssetUpload;
3306
- if (!uploadAsset) return;
3307
- const editor = editorInstanceRef.current;
3308
- if (!editor) return;
3309
- updateImageByUploadId(editor, uploadId, {
3310
- src: UPLOAD_PLACEHOLDER_SRC,
3319
+ const uploadFn = assets?.onAssetUpload;
3320
+ if (!uploadFn) return;
3321
+ const ed = editorRef.current;
3322
+ if (!ed) return;
3323
+ patchImage(ed, uploadId, {
3324
+ src: UPLOADING_SRC,
3311
3325
  alt: file.name,
3312
3326
  title: file.name,
3313
3327
  uploading: true,
@@ -3316,10 +3330,10 @@ const StudioTiptapEditor: React.FC<{ data: TiptapData }> = ({ data }) => {
3316
3330
  });
3317
3331
  const task = (async () => {
3318
3332
  try {
3319
- const url = await uploadAsset(file);
3320
- const currentEditor = editorInstanceRef.current;
3321
- if (currentEditor) {
3322
- updateImageByUploadId(currentEditor, uploadId, {
3333
+ const url = await uploadFn(file);
3334
+ const cur = editorRef.current;
3335
+ if (cur) {
3336
+ patchImage(cur, uploadId, {
3323
3337
  src: url,
3324
3338
  alt: file.name,
3325
3339
  title: file.name,
@@ -3328,302 +3342,433 @@ const StudioTiptapEditor: React.FC<{ data: TiptapData }> = ({ data }) => {
3328
3342
  awaitingUpload: false,
3329
3343
  uploadError: false,
3330
3344
  });
3331
- emitInlineUpdate(getEditorMarkdown(currentEditor));
3345
+ emit(getMarkdown(cur));
3332
3346
  }
3333
3347
  } catch (err) {
3334
3348
  console.error('[tiptap] upload failed', err);
3335
- const currentEditor = editorInstanceRef.current;
3336
- if (currentEditor) {
3337
- updateImageByUploadId(currentEditor, uploadId, {
3349
+ const cur = editorRef.current;
3350
+ if (cur)
3351
+ patchImage(cur, uploadId, {
3338
3352
  uploading: false,
3339
3353
  awaitingUpload: false,
3340
3354
  uploadError: true,
3341
3355
  });
3342
- }
3343
3356
  } finally {
3344
- pendingUploadsRef.current.delete(uploadId);
3357
+ pendingUploads.current.delete(uploadId);
3345
3358
  }
3346
3359
  })();
3347
- pendingUploadsRef.current.set(uploadId, task);
3360
+ pendingUploads.current.set(uploadId, task);
3348
3361
  await task;
3349
3362
  },
3350
- [assets, emitInlineUpdate]
3363
+ [assets, emit]
3351
3364
  );
3352
3365
 
3353
- const uploadImage = React.useCallback(
3366
+ const uploadFile = React.useCallback(
3354
3367
  async (file: File) => {
3355
- const uploadId = crypto.randomUUID();
3356
- insertUploadPlaceholder(uploadId, UPLOAD_PLACEHOLDER_SRC, false);
3357
- await startUploadForId(uploadId, file);
3368
+ const id = crypto.randomUUID();
3369
+ insertPlaceholder(id, UPLOADING_SRC, false);
3370
+ await doUpload(id, file);
3358
3371
  },
3359
- [insertUploadPlaceholder, startUploadForId]
3372
+ [insertPlaceholder, doUpload]
3360
3373
  );
3361
3374
 
3362
- const setFocusLock = React.useCallback((enabled: boolean) => {
3363
- const sectionEl = rootRef.current;
3364
- if (!sectionEl) return;
3365
- if (enabled) sectionEl.classList.add('jp-editorial-focus');
3366
- else sectionEl.classList.remove('jp-editorial-focus');
3367
- }, []);
3368
-
3369
- const editor = useEditor(
3370
- {
3371
- extensions: [
3372
- StarterKit,
3373
- Link.configure({ openOnClick: false, autolink: true }),
3374
- Underline,
3375
- UploadableImage,
3376
- Markdown.configure({ html: false }),
3377
- ],
3378
- content: data.content ?? '',
3379
- autofocus: false,
3380
- editorProps: {
3381
- attributes: {
3382
- class:
3383
- 'prose prose-invert max-w-none min-h-[220px] rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 text-zinc-100 outline-none',
3384
- },
3385
- handleDrop: (_view: unknown, event: DragEvent) => {
3386
- const file = event.dataTransfer?.files?.[0];
3387
- if (!file || !file.type.startsWith('image/') || !assets?.onAssetUpload) return false;
3388
- event.preventDefault();
3389
- void (async () => {
3390
- try {
3391
- await uploadImage(file);
3392
- } catch (err) {
3393
- console.error('[tiptap] upload failed on drop', err);
3394
- }
3395
- })();
3396
- return true;
3397
- },
3398
- handlePaste: (_view: unknown, event: ClipboardEvent) => {
3399
- const file = Array.from(event.clipboardData?.files ?? []).find((f: File) =>
3400
- f.type.startsWith('image/')
3401
- );
3402
- if (!file || !assets?.onAssetUpload) return false;
3403
- event.preventDefault();
3404
- void (async () => {
3405
- try {
3406
- await uploadImage(file);
3407
- } catch (err) {
3408
- console.error('[tiptap] upload failed on paste', err);
3409
- }
3410
- })();
3411
- return true;
3412
- },
3413
- handleClickOn: (_view: unknown, _pos: number, node: { type: { name: string }; attrs?: Record<string, unknown> }) => {
3414
- if (node.type.name !== 'image') return false;
3415
- if (node.attrs?.awaitingUpload !== true) return false;
3416
- const uploadId = typeof node.attrs?.uploadId === 'string' ? node.attrs.uploadId : null;
3417
- if (!uploadId) return false;
3418
- pendingPickerUploadIdRef.current = uploadId;
3419
- fileInputRef.current?.click();
3420
- return true;
3421
- },
3422
- },
3423
- onUpdate: ({ editor: currentEditor }: { editor: Editor }) => {
3424
- const markdown = getEditorMarkdown(currentEditor);
3425
- emitInlineUpdate(markdown);
3375
+ // ── Stable editorProps via refs (avoids stale closures in useEditor) ─────
3376
+ // Reads refs at call-time so useEditor never needs to rebuild the editor.
3377
+
3378
+ const uploadFileRef = React.useRef(uploadFile);
3379
+ uploadFileRef.current = uploadFile;
3380
+ const assetsRef = React.useRef(assets);
3381
+ assetsRef.current = assets;
3382
+
3383
+ const editorProps = React.useMemo(
3384
+ () => ({
3385
+ attributes: { class: EDITOR_CLASSES },
3386
+ handleDrop: (_v: unknown, event: DragEvent) => {
3387
+ const file = event.dataTransfer?.files?.[0];
3388
+ if (!file?.type.startsWith('image/') || !assetsRef.current?.onAssetUpload) return false;
3389
+ event.preventDefault();
3390
+ void uploadFileRef.current(file).catch((e) =>
3391
+ console.error('[tiptap] drop upload failed', e)
3392
+ );
3393
+ return true;
3426
3394
  },
3427
- onFocus: () => {
3428
- setFocusLock(true);
3395
+ handlePaste: (_v: unknown, event: ClipboardEvent) => {
3396
+ const file = Array.from(event.clipboardData?.files ?? []).find((f: File) =>
3397
+ f.type.startsWith('image/')
3398
+ );
3399
+ if (!file || !assetsRef.current?.onAssetUpload) return false;
3400
+ event.preventDefault();
3401
+ void uploadFileRef.current(file).catch((e) =>
3402
+ console.error('[tiptap] paste upload failed', e)
3403
+ );
3404
+ return true;
3429
3405
  },
3430
- onBlur: ({ editor: currentEditor }: { editor: Editor }) => {
3431
- const markdown = getEditorMarkdown(currentEditor);
3432
- if (markdown !== latestEmittedMarkdownRef.current) {
3433
- emitInlineUpdate(markdown);
3434
- }
3435
- setFocusLock(false);
3406
+ handleClickOn: (
3407
+ _v: unknown,
3408
+ _p: number,
3409
+ node: { type: { name: string }; attrs?: Record<string, unknown> }
3410
+ ) => {
3411
+ if (node.type.name !== 'image' || node.attrs?.awaitingUpload !== true) return false;
3412
+ const uploadId =
3413
+ typeof node.attrs?.uploadId === 'string' ? node.attrs.uploadId : null;
3414
+ if (!uploadId) return false;
3415
+ pendingPickerId.current = uploadId;
3416
+ fileInputRef.current?.click();
3417
+ return true;
3436
3418
  },
3437
- }
3419
+ }),
3420
+ [] // intentionally empty — reads refs at call-time
3438
3421
  );
3439
3422
 
3423
+ // ── useEditor ─────────────────────────────────────────────────────────────
3424
+
3425
+ const emitRef = React.useRef(emit);
3426
+ emitRef.current = emit;
3427
+
3428
+ const editor = useEditor({
3429
+ extensions: EXTENSIONS,
3430
+ content: data.content ?? '',
3431
+ autofocus: false,
3432
+ editorProps,
3433
+ onUpdate: ({ editor: e }: { editor: Editor }) => emitRef.current(getMarkdown(e)),
3434
+ onFocus: () => setFocusLock(true),
3435
+ onBlur: ({ editor: e }: { editor: Editor }) => {
3436
+ const md = getMarkdown(e);
3437
+ if (md !== emittedMd.current) emitRef.current(md);
3438
+ setFocusLock(false);
3439
+ },
3440
+ });
3441
+
3442
+ // ── Effects ───────────────────────────────────────────────────────────────
3443
+
3440
3444
  React.useEffect(() => {
3441
- const sectionEl = editorHostRef.current?.closest('[data-section-id]') as HTMLElement | null;
3442
- rootRef.current = sectionEl;
3445
+ sectionRef.current =
3446
+ hostRef.current?.closest('[data-section-id]') as HTMLElement | null;
3443
3447
  }, []);
3444
3448
 
3445
3449
  React.useEffect(() => {
3446
- editorInstanceRef.current = editor ?? null;
3450
+ editorRef.current = editor ?? null;
3447
3451
  }, [editor]);
3448
3452
 
3453
+ // Sync external content changes into editor (e.g. engine-level undo)
3454
+ React.useEffect(() => {
3455
+ if (!editor) return;
3456
+ const next = data.content ?? '';
3457
+ if (next === latestMd.current) return;
3458
+ editor.commands.setContent(next);
3459
+ latestMd.current = next;
3460
+ }, [data.content, editor]);
3461
+
3462
+ // PreviewEntry receives REQUEST_INLINE_FLUSH via postMessage and re-dispatches
3463
+ // it as a DOM CustomEvent. Listen to the DOM event — do NOT send INLINE_FLUSHED
3464
+ // back (PreviewEntry already handles that acknowledgement).
3449
3465
  React.useEffect(() => {
3450
- const handleInlineFlushRequest = () => {
3466
+ const handler = () => {
3451
3467
  void (async () => {
3452
- const pending = Array.from(pendingUploadsRef.current.values());
3453
- if (pending.length > 0) {
3454
- await Promise.allSettled(pending);
3468
+ if (pendingUploads.current.size > 0) {
3469
+ await Promise.allSettled(Array.from(pendingUploads.current.values()));
3455
3470
  }
3456
- const markdown = getEditorMarkdown(editorInstanceRef.current);
3457
- emitInlineUpdate(markdown);
3471
+ emitRef.current(getMarkdown(editorRef.current));
3458
3472
  })();
3459
3473
  };
3460
- window.addEventListener(STUDIO_EVENTS.REQUEST_INLINE_FLUSH, handleInlineFlushRequest as EventListener);
3461
- return () => {
3462
- window.removeEventListener(STUDIO_EVENTS.REQUEST_INLINE_FLUSH, handleInlineFlushRequest as EventListener);
3463
- };
3464
- }, [emitInlineUpdate]);
3474
+ window.addEventListener(STUDIO_EVENTS.REQUEST_INLINE_FLUSH, handler);
3475
+ return () => window.removeEventListener(STUDIO_EVENTS.REQUEST_INLINE_FLUSH, handler);
3476
+ }, []);
3465
3477
 
3478
+ // File input cancel: modern browsers fire a 'cancel' event when user
3479
+ // closes the picker without selecting a file.
3466
3480
  React.useEffect(() => {
3467
- if (!editor) return;
3468
- const nextMarkdown = data.content ?? '';
3469
- if (nextMarkdown === latestMarkdownRef.current) return;
3470
- editor.commands.setContent(nextMarkdown);
3471
- latestMarkdownRef.current = nextMarkdown;
3472
- }, [data.content, editor]);
3481
+ const input = fileInputRef.current;
3482
+ if (!input) return;
3483
+ const onCancel = () => {
3484
+ const pickId = pendingPickerId.current;
3485
+ if (pickId && editorRef.current) {
3486
+ patchImage(editorRef.current, pickId, {
3487
+ uploading: false,
3488
+ awaitingUpload: false,
3489
+ uploadError: true,
3490
+ });
3491
+ }
3492
+ pendingPickerId.current = null;
3493
+ };
3494
+ input.addEventListener('cancel', onCancel);
3495
+ return () => input.removeEventListener('cancel', onCancel);
3496
+ }, []);
3473
3497
 
3498
+ // Emit on unmount (safety flush)
3474
3499
  React.useEffect(
3475
3500
  () => () => {
3476
- const editorInstance = editorInstanceRef.current;
3477
- const markdown = getEditorMarkdown(editorInstance);
3478
- if (markdown !== latestEmittedMarkdownRef.current) {
3479
- emitInlineUpdate(markdown);
3480
- }
3501
+ const md = getMarkdown(editorRef.current);
3502
+ if (md !== emittedMd.current) emitRef.current(md);
3481
3503
  setFocusLock(false);
3482
3504
  },
3483
- [emitInlineUpdate, setFocusLock]
3505
+ [setFocusLock]
3484
3506
  );
3485
3507
 
3486
- const setParagraph = () => editor?.chain().focus().setParagraph().run();
3487
- const setHeading = (level: 1 | 2 | 3) => editor?.chain().focus().toggleHeading({ level }).run();
3488
- const setLink = () => {
3508
+ // Focus link input when popover opens
3509
+ React.useEffect(() => {
3510
+ if (linkOpen) {
3511
+ const t = setTimeout(() => linkInputRef.current?.focus(), 0);
3512
+ return () => clearTimeout(t);
3513
+ }
3514
+ }, [linkOpen]);
3515
+
3516
+ // ── Toolbar actions ───────────────────────────────────────────────────────
3517
+
3518
+ const openLink = () => {
3489
3519
  if (!editor) return;
3490
3520
  const prev = editor.getAttributes('link').href as string | undefined;
3491
- const href = window.prompt('URL', prev ?? 'https://');
3492
- if (href == null) return;
3493
- if (href.trim() === '') {
3521
+ setLinkUrl(prev ?? 'https://');
3522
+ setLinkOpen(true);
3523
+ };
3524
+
3525
+ const applyLink = () => {
3526
+ if (!editor) return;
3527
+ const href = linkUrl.trim();
3528
+ if (href === '' || href === 'https://') {
3494
3529
  editor.chain().focus().unsetLink().run();
3495
- return;
3530
+ } else {
3531
+ editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
3496
3532
  }
3497
- editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
3498
- };
3499
- const clearFormatting = () => {
3500
- editor?.chain().focus().unsetAllMarks().clearNodes().run();
3501
- };
3502
- const onPickImageClick = () => {
3503
- if (pendingPickerUploadIdRef.current) return;
3504
- const uploadId = crypto.randomUUID();
3505
- pendingPickerUploadIdRef.current = uploadId;
3506
- insertUploadPlaceholder(uploadId, UPLOAD_PICKER_PLACEHOLDER_SRC, true);
3533
+ setLinkOpen(false);
3507
3534
  };
3508
- const onFileSelected = (event: React.ChangeEvent<HTMLInputElement>) => {
3509
- const file = event.target.files?.[0];
3510
- const pendingUploadId = pendingPickerUploadIdRef.current;
3511
- if (!file || !file.type.startsWith('image/') || !assets?.onAssetUpload) {
3512
- event.target.value = '';
3535
+
3536
+ const onFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
3537
+ const file = e.target.files?.[0];
3538
+ const pickId = pendingPickerId.current;
3539
+ e.target.value = '';
3540
+
3541
+ if (!file?.type.startsWith('image/') || !assets?.onAssetUpload) {
3542
+ // File picker opened but no valid file selected — clean up placeholder
3543
+ if (pickId && editorRef.current) {
3544
+ patchImage(editorRef.current, pickId, {
3545
+ uploading: false,
3546
+ awaitingUpload: false,
3547
+ uploadError: true,
3548
+ });
3549
+ }
3550
+ pendingPickerId.current = null;
3513
3551
  return;
3514
3552
  }
3553
+
3515
3554
  void (async () => {
3516
3555
  try {
3517
- if (pendingUploadId) {
3518
- await startUploadForId(pendingUploadId, file);
3519
- pendingPickerUploadIdRef.current = null;
3556
+ if (pickId) {
3557
+ await doUpload(pickId, file);
3558
+ pendingPickerId.current = null;
3520
3559
  } else {
3521
- await uploadImage(file);
3560
+ await uploadFile(file);
3522
3561
  }
3523
3562
  } catch (err) {
3524
- console.error('[tiptap] upload failed from picker', err);
3525
- } finally {
3526
- event.target.value = '';
3563
+ console.error('[tiptap] picker upload failed', err);
3564
+ pendingPickerId.current = null;
3527
3565
  }
3528
3566
  })();
3529
3567
  };
3530
- const isActive = (name: string, attrs?: Record<string, unknown>) => editor?.isActive(name, attrs) ?? false;
3568
+
3569
+ const onPickImage = () => {
3570
+ if (pendingPickerId.current) return;
3571
+ const id = crypto.randomUUID();
3572
+ pendingPickerId.current = id;
3573
+ insertPlaceholder(id, PICKER_SRC, true);
3574
+ };
3575
+
3576
+ const isActive = (name: string, attrs?: Record<string, unknown>) =>
3577
+ editor?.isActive(name, attrs) ?? false;
3578
+
3579
+ // ── Render ────────────────────────────────────────────────────────────────
3531
3580
 
3532
3581
  return (
3533
- <div
3534
- ref={editorHostRef}
3535
- data-jp-field="content"
3536
- className="space-y-3"
3537
- >
3582
+ <div ref={hostRef} data-jp-field="content" className="space-y-2">
3538
3583
  {editor && (
3539
3584
  <div
3540
3585
  data-jp-ignore-select="true"
3541
- className="sticky top-2 z-[65] rounded-md border border-zinc-700 bg-zinc-950/95 p-2 shadow-lg backdrop-blur"
3586
+ className="sticky top-0 z-[65] border-b border-zinc-800 bg-zinc-950"
3542
3587
  >
3543
- <div className="flex flex-wrap items-center gap-1.5">
3544
- <ToolbarButton title="Undo" onClick={() => editor.chain().focus().undo().run()}>
3545
- <Undo2 size={14} />
3546
- </ToolbarButton>
3547
- <ToolbarButton title="Redo" onClick={() => editor.chain().focus().redo().run()}>
3548
- <Redo2 size={14} />
3549
- </ToolbarButton>
3550
- <ToolbarSeparator />
3551
-
3552
- <ToolbarButton active={isActive('paragraph')} title="Paragraph" onClick={setParagraph}>
3588
+ {/* ── Main toolbar ── */}
3589
+ <div className="flex flex-wrap items-center justify-center gap-1 p-2">
3590
+ {/* History */}
3591
+ <Btn title="Undo" onClick={() => editor.chain().focus().undo().run()}>
3592
+ <Undo2 size={13} />
3593
+ </Btn>
3594
+ <Btn title="Redo" onClick={() => editor.chain().focus().redo().run()}>
3595
+ <Redo2 size={13} />
3596
+ </Btn>
3597
+ <Sep />
3598
+
3599
+ {/* Block type */}
3600
+ <Btn
3601
+ active={isActive('paragraph')}
3602
+ title="Paragraph"
3603
+ onClick={() => editor.chain().focus().setParagraph().run()}
3604
+ >
3553
3605
  P
3554
- </ToolbarButton>
3555
- <ToolbarButton active={isActive('heading', { level: 1 })} title="Heading 1" onClick={() => setHeading(1)}>
3606
+ </Btn>
3607
+ <Btn
3608
+ active={isActive('heading', { level: 1 })}
3609
+ title="Heading 1"
3610
+ onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
3611
+ >
3556
3612
  H1
3557
- </ToolbarButton>
3558
- <ToolbarButton active={isActive('heading', { level: 2 })} title="Heading 2" onClick={() => setHeading(2)}>
3613
+ </Btn>
3614
+ <Btn
3615
+ active={isActive('heading', { level: 2 })}
3616
+ title="Heading 2"
3617
+ onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
3618
+ >
3559
3619
  H2
3560
- </ToolbarButton>
3561
- <ToolbarButton active={isActive('heading', { level: 3 })} title="Heading 3" onClick={() => setHeading(3)}>
3620
+ </Btn>
3621
+ <Btn
3622
+ active={isActive('heading', { level: 3 })}
3623
+ title="Heading 3"
3624
+ onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
3625
+ >
3562
3626
  H3
3563
- </ToolbarButton>
3564
- <ToolbarButton title="Headings" onClick={() => setHeading(2)}>
3565
- <Heading size={14} />
3566
- </ToolbarButton>
3567
- <ToolbarSeparator />
3568
-
3569
- <ToolbarButton active={isActive('bold')} title="Bold" onClick={() => editor.chain().focus().toggleBold().run()}>
3570
- <Bold size={14} />
3571
- </ToolbarButton>
3572
- <ToolbarButton active={isActive('italic')} title="Italic" onClick={() => editor.chain().focus().toggleItalic().run()}>
3573
- <Italic size={14} />
3574
- </ToolbarButton>
3575
- <ToolbarButton active={isActive('underline')} title="Underline" onClick={() => editor.chain().focus().toggleUnderline().run()}>
3576
- <UnderlineIcon size={14} />
3577
- </ToolbarButton>
3578
- <ToolbarButton active={isActive('strike')} title="Strike" onClick={() => editor.chain().focus().toggleStrike().run()}>
3579
- <Strikethrough size={14} />
3580
- </ToolbarButton>
3581
- <ToolbarButton active={isActive('code')} title="Inline code" onClick={() => editor.chain().focus().toggleCode().run()}>
3582
- <Code2 size={14} />
3583
- </ToolbarButton>
3584
- <ToolbarSeparator />
3585
-
3586
- <ToolbarButton active={isActive('bulletList')} title="Bullet list" onClick={() => editor.chain().focus().toggleBulletList().run()}>
3587
- <List size={14} />
3588
- </ToolbarButton>
3589
- <ToolbarButton active={isActive('orderedList')} title="Ordered list" onClick={() => editor.chain().focus().toggleOrderedList().run()}>
3590
- <ListOrdered size={14} />
3591
- </ToolbarButton>
3592
- <ToolbarButton active={isActive('blockquote')} title="Blockquote" onClick={() => editor.chain().focus().toggleBlockquote().run()}>
3593
- <Quote size={14} />
3594
- </ToolbarButton>
3595
- <ToolbarButton active={isActive('codeBlock')} title="Code block" onClick={() => editor.chain().focus().toggleCodeBlock().run()}>
3596
- <SquareCode size={14} />
3597
- </ToolbarButton>
3598
- <ToolbarSeparator />
3599
-
3600
- <ToolbarButton active={isActive('link')} title="Set link" onClick={setLink}>
3601
- <Link2 size={14} />
3602
- </ToolbarButton>
3603
- <ToolbarButton title="Remove link" onClick={() => editor.chain().focus().unsetLink().run()}>
3604
- <Unlink2 size={14} />
3605
- </ToolbarButton>
3606
- <ToolbarButton title="Add image" onClick={onPickImageClick}>
3607
- <ImagePlus size={14} />
3608
- </ToolbarButton>
3609
- <ToolbarButton title="Clear formatting" onClick={clearFormatting}>
3610
- <Eraser size={14} />
3611
- </ToolbarButton>
3627
+ </Btn>
3628
+ <Sep />
3629
+
3630
+ {/* Inline marks */}
3631
+ <Btn
3632
+ active={isActive('bold')}
3633
+ title="Bold (Ctrl+B)"
3634
+ onClick={() => editor.chain().focus().toggleBold().run()}
3635
+ >
3636
+ <Bold size={13} />
3637
+ </Btn>
3638
+ <Btn
3639
+ active={isActive('italic')}
3640
+ title="Italic (Ctrl+I)"
3641
+ onClick={() => editor.chain().focus().toggleItalic().run()}
3642
+ >
3643
+ <Italic size={13} />
3644
+ </Btn>
3645
+ <Btn
3646
+ active={isActive('strike')}
3647
+ title="Strikethrough"
3648
+ onClick={() => editor.chain().focus().toggleStrike().run()}
3649
+ >
3650
+ <Strikethrough size={13} />
3651
+ </Btn>
3652
+ <Btn
3653
+ active={isActive('code')}
3654
+ title="Inline code"
3655
+ onClick={() => editor.chain().focus().toggleCode().run()}
3656
+ >
3657
+ <Code2 size={13} />
3658
+ </Btn>
3659
+ <Sep />
3660
+
3661
+ {/* Lists & block nodes */}
3662
+ <Btn
3663
+ active={isActive('bulletList')}
3664
+ title="Bullet list"
3665
+ onClick={() => editor.chain().focus().toggleBulletList().run()}
3666
+ >
3667
+ <List size={13} />
3668
+ </Btn>
3669
+ <Btn
3670
+ active={isActive('orderedList')}
3671
+ title="Ordered list"
3672
+ onClick={() => editor.chain().focus().toggleOrderedList().run()}
3673
+ >
3674
+ <ListOrdered size={13} />
3675
+ </Btn>
3676
+ <Btn
3677
+ active={isActive('blockquote')}
3678
+ title="Blockquote"
3679
+ onClick={() => editor.chain().focus().toggleBlockquote().run()}
3680
+ >
3681
+ <Quote size={13} />
3682
+ </Btn>
3683
+ <Btn
3684
+ active={isActive('codeBlock')}
3685
+ title="Code block"
3686
+ onClick={() => editor.chain().focus().toggleCodeBlock().run()}
3687
+ >
3688
+ <SquareCode size={13} />
3689
+ </Btn>
3690
+ <Sep />
3691
+
3692
+ {/* Link / image / clear */}
3693
+ <Btn
3694
+ active={isActive('link') || linkOpen}
3695
+ title="Set link"
3696
+ onClick={openLink}
3697
+ >
3698
+ <Link2 size={13} />
3699
+ </Btn>
3700
+ <Btn
3701
+ title="Remove link"
3702
+ onClick={() => editor.chain().focus().unsetLink().run()}
3703
+ >
3704
+ <Unlink2 size={13} />
3705
+ </Btn>
3706
+ <Btn title="Insert image" onClick={onPickImage}>
3707
+ <ImagePlus size={13} />
3708
+ </Btn>
3709
+ <Btn
3710
+ title="Clear formatting"
3711
+ onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}
3712
+ >
3713
+ <Eraser size={13} />
3714
+ </Btn>
3612
3715
  </div>
3613
- <input
3614
- ref={fileInputRef}
3615
- type="file"
3616
- accept="image/*"
3617
- className="hidden"
3618
- onChange={onFileSelected}
3619
- />
3716
+
3717
+ {/* ── Link popover row (replaces window.prompt) ── */}
3718
+ {linkOpen && (
3719
+ <div className="flex items-center gap-2 border-t border-zinc-700 px-2 py-1.5">
3720
+ <Link2 size={12} className="shrink-0 text-zinc-500" />
3721
+ <input
3722
+ ref={linkInputRef}
3723
+ type="url"
3724
+ value={linkUrl}
3725
+ onChange={(e) => setLinkUrl(e.target.value)}
3726
+ onKeyDown={(e) => {
3727
+ if (e.key === 'Enter') {
3728
+ e.preventDefault();
3729
+ applyLink();
3730
+ }
3731
+ if (e.key === 'Escape') setLinkOpen(false);
3732
+ }}
3733
+ placeholder="https://example.com"
3734
+ className="min-w-0 flex-1 bg-transparent text-xs text-zinc-100 placeholder:text-zinc-500 outline-none"
3735
+ />
3736
+ <button
3737
+ type="button"
3738
+ onMouseDown={(e) => e.preventDefault()}
3739
+ onClick={applyLink}
3740
+ className="shrink-0 rounded px-2 py-0.5 text-xs bg-blue-600 hover:bg-blue-500 text-white transition-colors"
3741
+ >
3742
+ Set
3743
+ </button>
3744
+ <button
3745
+ type="button"
3746
+ onMouseDown={(e) => e.preventDefault()}
3747
+ onClick={() => setLinkOpen(false)}
3748
+ className="shrink-0 rounded px-2 py-0.5 text-xs bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors"
3749
+ >
3750
+ Cancel
3751
+ </button>
3752
+ </div>
3753
+ )}
3620
3754
  </div>
3621
3755
  )}
3756
+
3622
3757
  <EditorContent editor={editor} className="jp-simple-editor" />
3758
+
3759
+ <input
3760
+ ref={fileInputRef}
3761
+ type="file"
3762
+ accept="image/*"
3763
+ className="hidden"
3764
+ onChange={onFileSelected}
3765
+ />
3623
3766
  </div>
3624
3767
  );
3625
3768
  };
3626
3769
 
3770
+ // ── Public view ───────────────────────────────────────────────────────────────
3771
+
3627
3772
  const PublicTiptapContent: React.FC<{ content: string }> = ({ content }) => (
3628
3773
  <article className="prose max-w-none prose-zinc" data-jp-field="content">
3629
3774
  <ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>
@@ -3632,25 +3777,17 @@ const PublicTiptapContent: React.FC<{ content: string }> = ({ content }) => (
3632
3777
  </article>
3633
3778
  );
3634
3779
 
3780
+ // ── Export ────────────────────────────────────────────────────────────────────
3781
+
3635
3782
  export const Tiptap: React.FC<{ data: TiptapData; settings?: TiptapSettings }> = ({ data }) => {
3636
3783
  const { mode } = useStudio();
3637
- const isStudio = mode === 'studio';
3638
-
3639
3784
  return (
3640
- <section
3641
- style={
3642
- {
3643
- '--local-bg': 'var(--background)',
3644
- '--local-surface': 'var(--card)',
3645
- '--local-text': 'var(--foreground)',
3646
- '--local-border': 'var(--border)',
3647
- } as React.CSSProperties
3648
- }
3649
- className="py-12 bg-[var(--local-bg)]"
3650
- >
3651
- <div className="container mx-auto px-6 max-w-4xl">
3652
- {isStudio ? <StudioTiptapEditor data={data} /> : <PublicTiptapContent content={data.content ?? ''} />}
3653
- </div>
3785
+ <section className="w-full">
3786
+ {mode === 'studio' ? (
3787
+ <StudioTiptapEditor data={data} />
3788
+ ) : (
3789
+ <PublicTiptapContent content={data.content ?? ''} />
3790
+ )}
3654
3791
  </section>
3655
3792
  );
3656
3793
  };
@@ -4984,7 +5121,7 @@ cat << 'END_OF_FILE_CONTENT' > "src/data/pages/post.json"
4984
5121
  "id": "post-editorial-main",
4985
5122
  "type": "tiptap",
4986
5123
  "data": {
4987
- "content": "![Screenshot 2026-03-05 at 14-45-10 JsonPages.png](/assets/images/1772721027290-Screenshot_2026-03-05_at_14-45-10_JsonPages.png \"Screenshot 2026-03-05 at 14-45-10 JsonPages.png\")## qefqfqfqf**header / tiptap / footer** smoke flow.\n\nInline **editing** in Studio\n\n## `Markdown persistence on Save`\n\n- Safe render on public route"
5124
+ "content": "# JsonPages Cloud – Terms of Service & EULA\n\n---\n\n### **Last Updated:** March 2026\n\n### 1. THE SERVICE\n\nJsonPages provides a hybrid content management infrastructure consisting of:\n\n- **The Core:** An open-source library (@jsonpages/core) governed by the **MIT License**.\n- **The Cloud:** A proprietary SaaS platform (`cloud.jsonpages.io`) that provides the \"Git Bridge,\" Asset Pipeline, and Managed Infrastructure.\n\nBy using the Cloud Service, you agree to these terms.\n\n### 2. DATA SOVEREIGNTY & OWNERSHIP\n\n- **Your Content:** All data (JSON files), code, and assets managed through JsonPages remain your exclusive property. JsonPages acts only as an **orchestrator**.\n- **The Bridge:** You grant JsonPages the necessary permissions to perform Git operations (commits/pushes) on your behalf to your designated repositories (GitHub/GitLab).\n- **Portability:** Since your content is stored as flat JSON files in your own repository, you retain the right to migrate away from the Cloud Service at any time without data lock-in.\n- \n\n### 3. SUBSCRIPTIONS & ENTITLEMENTS\n\n- **Billing:** The Cloud Service is billed on a subscription basis (**Monthly Recurring Revenue**).\n- **Entitlements:** Each \"Project\" or \"Tenant\" consumes one entitlement. Active entitlements grant access to the Visual Studio (ICE) and the Cloud Save API.\n- **Third-Party Costs:** You are solely responsible for any costs incurred on third-party platforms (e.g., **Vercel** hosting, **GitHub** storage, **Cloudflare** workers).\n- \n\n### 4. ACCEPTABLE USE\n\nYou may not use JsonPages Cloud to:\n\n- Host or manage illegal, harmful, or offensive content.\n- Attempt to reverse-engineer the proprietary Cloud Bridge or bypass entitlement checks.\n- Interfere with the stability of the API for other users.\n- \n\n### 5. LIMITATION OF LIABILITY\n\n- **\"As-Is\" Basis:** The service is provided \"as-is.\" While we strive for 99.9% uptime, JsonPages is not liable for data loss resulting from Git conflicts, third-party outages (Vercel/GitHub), or user error.\n- **No Warranty:** We do not warrant that the service will be error-free or uninterrupted.\n- \n\n### 6. TERMINATION\n\n- **By You:** You can cancel your subscription at any time. Your Studio access will remain active until the end of the current billing cycle.\n- \n- **By Us:** We reserve the right to suspend accounts that violate these terms or fail to settle outstanding invoices.\n\n### 7. GOVERNING LAW\n\nThese terms are governed by the laws of **Italy/European Union**, without regard to conflict of law principles."
4988
5125
  },
4989
5126
  "settings": {}
4990
5127
  }
@@ -4994,35 +5131,35 @@ END_OF_FILE_CONTENT
4994
5131
  mkdir -p "src/data/pages/servizi"
4995
5132
  echo "Creating src/data/pages/servizi/trattamento.json..."
4996
5133
  cat << 'END_OF_FILE_CONTENT' > "src/data/pages/servizi/trattamento.json"
4997
- {
4998
- "id": "servizi-trattamento-page",
4999
- "slug": "servizi/trattamento",
5000
- "meta": {
5001
- "title": "Servizi - Trattamento",
5002
- "description": "Pagina nested di smoke test per verificare routing visitor/admin/preview."
5003
- },
5004
- "sections": [
5005
- {
5006
- "id": "hero-servizi-trattamento",
5007
- "type": "hero",
5008
- "data": {
5009
- "badge": "Smoke Test",
5010
- "title": "Trattamento",
5011
- "titleHighlight": "Pagina Nested",
5012
- "description": "Questa pagina verifica il supporto ai nested slug su filesystem e router.",
5013
- "ctas": [
5014
- {
5015
- "id": "cta-home",
5016
- "label": "Torna Home",
5017
- "href": "/",
5018
- "variant": "primary"
5019
- }
5020
- ]
5021
- },
5022
- "settings": {}
5023
- }
5024
- ]
5025
- }
5134
+ {
5135
+ "id": "servizi-trattamento-page",
5136
+ "slug": "servizi/trattamento",
5137
+ "meta": {
5138
+ "title": "Servizi - Trattamento",
5139
+ "description": "Pagina nested di smoke test per verificare routing visitor/admin/preview."
5140
+ },
5141
+ "sections": [
5142
+ {
5143
+ "id": "hero-servizi-trattamento",
5144
+ "type": "hero",
5145
+ "data": {
5146
+ "badge": "Smoke Test",
5147
+ "title": "Trattamento",
5148
+ "titleHighlight": "Pagina Nested",
5149
+ "description": "Questa pagina verifica il supporto ai nested slug su filesystem e router.",
5150
+ "ctas": [
5151
+ {
5152
+ "id": "cta-home",
5153
+ "label": "Torna Home",
5154
+ "href": "/",
5155
+ "variant": "primary"
5156
+ }
5157
+ ]
5158
+ },
5159
+ "settings": {}
5160
+ }
5161
+ ]
5162
+ }
5026
5163
 
5027
5164
  END_OF_FILE_CONTENT
5028
5165
  echo "Creating src/data/pages/servizi_trattamento.json..."
@@ -5275,9 +5412,65 @@ html {
5275
5412
  pointer-events: none;
5276
5413
  }
5277
5414
 
5278
- /* Tiptap simple-editor-like behavior in Studio */
5279
- .jp-simple-editor .ProseMirror:focus {
5415
+ /* ==========================================================================
5416
+ TIPTAP / PROSEMIRROR — Editor typography
5417
+ Tailwind preflight resets all heading/list styles. Re-apply here using
5418
+ tenant theme tokens so the editor is WYSIWYG.
5419
+ ========================================================================== */
5420
+ .jp-simple-editor .ProseMirror {
5280
5421
  outline: none;
5422
+ word-break: break-word;
5423
+ }
5424
+ .jp-simple-editor .ProseMirror > * + * { margin-top: 0.75em; }
5425
+
5426
+ .jp-simple-editor .ProseMirror h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
5427
+ .jp-simple-editor .ProseMirror h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
5428
+ .jp-simple-editor .ProseMirror h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
5429
+ .jp-simple-editor .ProseMirror h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
5430
+
5431
+ .jp-simple-editor .ProseMirror p { line-height: 1.7; }
5432
+
5433
+ .jp-simple-editor .ProseMirror strong { font-weight: 700; }
5434
+ .jp-simple-editor .ProseMirror em { font-style: italic; }
5435
+ .jp-simple-editor .ProseMirror s { text-decoration: line-through; }
5436
+
5437
+ .jp-simple-editor .ProseMirror a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
5438
+ .jp-simple-editor .ProseMirror a:hover { opacity: 0.8; }
5439
+
5440
+ .jp-simple-editor .ProseMirror code {
5441
+ font-family: var(--font-mono, ui-monospace, monospace);
5442
+ font-size: 0.875em;
5443
+ background: color-mix(in oklch, var(--foreground) 8%, transparent);
5444
+ border-radius: 0.25em;
5445
+ padding: 0.1em 0.35em;
5446
+ }
5447
+ .jp-simple-editor .ProseMirror pre {
5448
+ background: color-mix(in oklch, var(--background) 60%, black);
5449
+ border-radius: 0.5em;
5450
+ padding: 1em 1.25em;
5451
+ overflow-x: auto;
5452
+ }
5453
+ .jp-simple-editor .ProseMirror pre code {
5454
+ background: none;
5455
+ padding: 0;
5456
+ }
5457
+
5458
+ .jp-simple-editor .ProseMirror ul { list-style-type: disc; padding-left: 1.625em; }
5459
+ .jp-simple-editor .ProseMirror ol { list-style-type: decimal; padding-left: 1.625em; }
5460
+ .jp-simple-editor .ProseMirror li { line-height: 1.7; margin-top: 0.25em; }
5461
+ .jp-simple-editor .ProseMirror li + li { margin-top: 0.25em; }
5462
+
5463
+ .jp-simple-editor .ProseMirror blockquote {
5464
+ border-left: 3px solid var(--border);
5465
+ padding-left: 1em;
5466
+ color: var(--muted-foreground);
5467
+ font-style: italic;
5468
+ }
5469
+
5470
+ .jp-simple-editor .ProseMirror hr {
5471
+ border: none;
5472
+ border-top: 1px solid var(--border);
5473
+ margin: 1.5em 0;
5281
5474
  }
5282
5475
 
5283
5476
  .jp-simple-editor .ProseMirror img {
@@ -5298,6 +5491,16 @@ html {
5298
5491
  outline-offset: 2px;
5299
5492
  }
5300
5493
 
5494
+ /* Placeholder when editor is empty */
5495
+ .jp-simple-editor .ProseMirror p.is-editor-empty:first-child::before {
5496
+ content: attr(data-placeholder);
5497
+ color: var(--muted-foreground);
5498
+ opacity: 0.5;
5499
+ pointer-events: none;
5500
+ float: left;
5501
+ height: 0;
5502
+ }
5503
+
5301
5504
 
5302
5505
 
5303
5506
  END_OF_FILE_CONTENT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsonpages/cli",
3
- "version": "3.0.61",
3
+ "version": "3.0.63",
4
4
  "description": "The Sovereign CLI Engine for JsonPages.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -134,7 +134,9 @@ program
134
134
  await processScriptInNode(scriptPath, targetDir);
135
135
  spinner.succeed('Source code and assets projected successfully.');
136
136
 
137
- // 5. Dependencies: gestite nello script .sh (es. copia/package.json da tenant-alpha o npm install)
137
+ // 5. Install dependencies (lo script .sh deve aver già scritto/copiato package.json in targetDir)
138
+ spinner.start('Installing dependencies (this may take a minute)...');
139
+ await execa(npmCmd, ['install'], { cwd: targetDir });
138
140
  spinner.succeed(chalk.green.bold('✨ Tenant Ready!'));
139
141
 
140
142
  console.log(`\n${chalk.white.bgBlue(' NEXT STEPS ')}`);