@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.
- package/assets/src_tenant_alpha.sh +616 -413
- package/package.json +1 -1
- package/src/index.js +3 -1
|
@@ -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": "
|
|
43
|
-
"@tiptap/extension-link": "
|
|
44
|
-
"@tiptap/
|
|
45
|
-
"@tiptap/
|
|
46
|
-
"@
|
|
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": "
|
|
50
|
+
"react-markdown": "^9.0.1",
|
|
52
51
|
"react-dom": "^19.0.0",
|
|
53
52
|
"react-router-dom": "^6.30.3",
|
|
54
|
-
"rehype-sanitize": "
|
|
55
|
-
"remark-gfm": "
|
|
53
|
+
"rehype-sanitize": "^6.0.0",
|
|
54
|
+
"remark-gfm": "^4.0.1",
|
|
56
55
|
"tailwind-merge": "^3.0.1",
|
|
57
|
-
"tiptap-markdown": "
|
|
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
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
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
|
-
|
|
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
|
|
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-
|
|
3149
|
-
: '
|
|
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
|
|
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: (
|
|
3165
|
-
renderHTML: (
|
|
3166
|
-
|
|
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
|
-
|
|
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
|
|
3202
|
-
|
|
3203
|
-
|
|
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
|
|
3214
|
-
|
|
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
|
|
3226
|
-
|
|
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 & 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
|
|
3231
|
-
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
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
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
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
|
-
|
|
3249
|
-
|
|
3250
|
-
const
|
|
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
|
-
|
|
3253
|
-
|
|
3254
|
-
const
|
|
3255
|
-
const
|
|
3256
|
-
|
|
3257
|
-
|
|
3258
|
-
|
|
3259
|
-
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
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
|
|
3266
|
-
|
|
3267
|
-
|
|
3268
|
-
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
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
|
|
3296
|
+
const insertPlaceholder = React.useCallback(
|
|
3282
3297
|
(uploadId: string, src: string, awaitingUpload: boolean) => {
|
|
3283
|
-
const
|
|
3284
|
-
if (!
|
|
3285
|
-
|
|
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
|
-
|
|
3312
|
+
emit(getMarkdown(ed));
|
|
3299
3313
|
},
|
|
3300
|
-
[
|
|
3314
|
+
[emit]
|
|
3301
3315
|
);
|
|
3302
3316
|
|
|
3303
|
-
const
|
|
3317
|
+
const doUpload = React.useCallback(
|
|
3304
3318
|
async (uploadId: string, file: File) => {
|
|
3305
|
-
const
|
|
3306
|
-
if (!
|
|
3307
|
-
const
|
|
3308
|
-
if (!
|
|
3309
|
-
|
|
3310
|
-
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
|
|
3320
|
-
const
|
|
3321
|
-
if (
|
|
3322
|
-
|
|
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
|
-
|
|
3345
|
+
emit(getMarkdown(cur));
|
|
3332
3346
|
}
|
|
3333
3347
|
} catch (err) {
|
|
3334
3348
|
console.error('[tiptap] upload failed', err);
|
|
3335
|
-
const
|
|
3336
|
-
if (
|
|
3337
|
-
|
|
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
|
-
|
|
3357
|
+
pendingUploads.current.delete(uploadId);
|
|
3345
3358
|
}
|
|
3346
3359
|
})();
|
|
3347
|
-
|
|
3360
|
+
pendingUploads.current.set(uploadId, task);
|
|
3348
3361
|
await task;
|
|
3349
3362
|
},
|
|
3350
|
-
[assets,
|
|
3363
|
+
[assets, emit]
|
|
3351
3364
|
);
|
|
3352
3365
|
|
|
3353
|
-
const
|
|
3366
|
+
const uploadFile = React.useCallback(
|
|
3354
3367
|
async (file: File) => {
|
|
3355
|
-
const
|
|
3356
|
-
|
|
3357
|
-
await
|
|
3368
|
+
const id = crypto.randomUUID();
|
|
3369
|
+
insertPlaceholder(id, UPLOADING_SRC, false);
|
|
3370
|
+
await doUpload(id, file);
|
|
3358
3371
|
},
|
|
3359
|
-
[
|
|
3372
|
+
[insertPlaceholder, doUpload]
|
|
3360
3373
|
);
|
|
3361
3374
|
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
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
|
-
|
|
3428
|
-
|
|
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
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
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
|
-
|
|
3442
|
-
|
|
3445
|
+
sectionRef.current =
|
|
3446
|
+
hostRef.current?.closest('[data-section-id]') as HTMLElement | null;
|
|
3443
3447
|
}, []);
|
|
3444
3448
|
|
|
3445
3449
|
React.useEffect(() => {
|
|
3446
|
-
|
|
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
|
|
3466
|
+
const handler = () => {
|
|
3451
3467
|
void (async () => {
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
await Promise.allSettled(pending);
|
|
3468
|
+
if (pendingUploads.current.size > 0) {
|
|
3469
|
+
await Promise.allSettled(Array.from(pendingUploads.current.values()));
|
|
3455
3470
|
}
|
|
3456
|
-
|
|
3457
|
-
emitInlineUpdate(markdown);
|
|
3471
|
+
emitRef.current(getMarkdown(editorRef.current));
|
|
3458
3472
|
})();
|
|
3459
3473
|
};
|
|
3460
|
-
window.addEventListener(STUDIO_EVENTS.REQUEST_INLINE_FLUSH,
|
|
3461
|
-
return () =>
|
|
3462
|
-
|
|
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
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
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
|
|
3477
|
-
|
|
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
|
-
[
|
|
3505
|
+
[setFocusLock]
|
|
3484
3506
|
);
|
|
3485
3507
|
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
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
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
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
|
-
|
|
3530
|
+
} else {
|
|
3531
|
+
editor.chain().focus().extendMarkRange('link').setLink({ href }).run();
|
|
3496
3532
|
}
|
|
3497
|
-
|
|
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
|
-
|
|
3509
|
-
|
|
3510
|
-
const
|
|
3511
|
-
|
|
3512
|
-
|
|
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 (
|
|
3518
|
-
await
|
|
3519
|
-
|
|
3556
|
+
if (pickId) {
|
|
3557
|
+
await doUpload(pickId, file);
|
|
3558
|
+
pendingPickerId.current = null;
|
|
3520
3559
|
} else {
|
|
3521
|
-
await
|
|
3560
|
+
await uploadFile(file);
|
|
3522
3561
|
}
|
|
3523
3562
|
} catch (err) {
|
|
3524
|
-
console.error('[tiptap] upload failed
|
|
3525
|
-
|
|
3526
|
-
event.target.value = '';
|
|
3563
|
+
console.error('[tiptap] picker upload failed', err);
|
|
3564
|
+
pendingPickerId.current = null;
|
|
3527
3565
|
}
|
|
3528
3566
|
})();
|
|
3529
3567
|
};
|
|
3530
|
-
|
|
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-
|
|
3586
|
+
className="sticky top-0 z-[65] border-b border-zinc-800 bg-zinc-950"
|
|
3542
3587
|
>
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
<
|
|
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
|
-
</
|
|
3555
|
-
<
|
|
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
|
-
</
|
|
3558
|
-
<
|
|
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
|
-
</
|
|
3561
|
-
<
|
|
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
|
-
</
|
|
3564
|
-
<
|
|
3565
|
-
|
|
3566
|
-
|
|
3567
|
-
<
|
|
3568
|
-
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
|
|
3572
|
-
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
<
|
|
3580
|
-
</
|
|
3581
|
-
<
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
<
|
|
3594
|
-
</
|
|
3595
|
-
<
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
<
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
<
|
|
3611
|
-
</
|
|
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
|
-
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
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
|
-
|
|
3642
|
-
{
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
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": "
|
|
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
|
-
/*
|
|
5279
|
-
|
|
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
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.
|
|
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 ')}`);
|