@olonjs/cli 3.0.131 → 3.0.133
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.
|
@@ -1674,7 +1674,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
|
|
|
1674
1674
|
"@tiptap/extension-link": "^2.11.5",
|
|
1675
1675
|
"@tiptap/react": "^2.11.5",
|
|
1676
1676
|
"@tiptap/starter-kit": "^2.11.5",
|
|
1677
|
-
"@olonjs/core": "^1.0.
|
|
1677
|
+
"@olonjs/core": "^1.0.124",
|
|
1678
1678
|
"class-variance-authority": "^0.7.1",
|
|
1679
1679
|
"clsx": "^2.1.1",
|
|
1680
1680
|
"lucide-react": "^0.474.0",
|
|
@@ -5144,6 +5144,608 @@ cat << 'END_OF_FILE_CONTENT' > "src/components/save-drawer/saverStyle.css"
|
|
|
5144
5144
|
}
|
|
5145
5145
|
|
|
5146
5146
|
|
|
5147
|
+
END_OF_FILE_CONTENT
|
|
5148
|
+
mkdir -p "src/components/tiptap"
|
|
5149
|
+
echo "Creating src/components/tiptap/INTEGRATION.md..."
|
|
5150
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/INTEGRATION.md"
|
|
5151
|
+
# Tiptap Editorial — Integration Guide
|
|
5152
|
+
|
|
5153
|
+
How to add the `tiptap` section to a new tenant.
|
|
5154
|
+
|
|
5155
|
+
---
|
|
5156
|
+
|
|
5157
|
+
## 1. Copy the component
|
|
5158
|
+
|
|
5159
|
+
Copy the entire folder into the new tenant:
|
|
5160
|
+
|
|
5161
|
+
```
|
|
5162
|
+
src/components/tiptap/
|
|
5163
|
+
index.ts
|
|
5164
|
+
types.ts
|
|
5165
|
+
View.tsx
|
|
5166
|
+
```
|
|
5167
|
+
|
|
5168
|
+
---
|
|
5169
|
+
|
|
5170
|
+
## 2. Install npm dependencies
|
|
5171
|
+
|
|
5172
|
+
Add to the tenant's `package.json` and run `npm install`:
|
|
5173
|
+
|
|
5174
|
+
```json
|
|
5175
|
+
"@tiptap/extension-image": "^2.11.5",
|
|
5176
|
+
"@tiptap/extension-link": "^2.11.5",
|
|
5177
|
+
"@tiptap/react": "^2.11.5",
|
|
5178
|
+
"@tiptap/starter-kit": "^2.11.5",
|
|
5179
|
+
"react-markdown": "^9.0.1",
|
|
5180
|
+
"rehype-sanitize": "^6.0.0",
|
|
5181
|
+
"remark-gfm": "^4.0.1",
|
|
5182
|
+
"tiptap-markdown": "^0.8.10"
|
|
5183
|
+
```
|
|
5184
|
+
|
|
5185
|
+
---
|
|
5186
|
+
|
|
5187
|
+
## 3. Add CSS to `src/index.css`
|
|
5188
|
+
|
|
5189
|
+
Two blocks are required — one for the public (visitor) view, one for the editor (studio) view.
|
|
5190
|
+
|
|
5191
|
+
```css
|
|
5192
|
+
/* ==========================================================================
|
|
5193
|
+
TIPTAP — Public content typography (visitor view)
|
|
5194
|
+
========================================================================== */
|
|
5195
|
+
.jp-tiptap-content > * + * { margin-top: 0.75em; }
|
|
5196
|
+
|
|
5197
|
+
.jp-tiptap-content h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
5198
|
+
.jp-tiptap-content h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
5199
|
+
.jp-tiptap-content h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
5200
|
+
.jp-tiptap-content h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
|
|
5201
|
+
|
|
5202
|
+
.jp-tiptap-content p { line-height: 1.7; }
|
|
5203
|
+
.jp-tiptap-content strong { font-weight: 700; }
|
|
5204
|
+
.jp-tiptap-content em { font-style: italic; }
|
|
5205
|
+
.jp-tiptap-content s { text-decoration: line-through; }
|
|
5206
|
+
|
|
5207
|
+
.jp-tiptap-content a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
|
|
5208
|
+
.jp-tiptap-content a:hover { opacity: 0.8; }
|
|
5209
|
+
|
|
5210
|
+
.jp-tiptap-content code {
|
|
5211
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
5212
|
+
font-size: 0.875em;
|
|
5213
|
+
background: color-mix(in oklch, var(--foreground) 8%, transparent);
|
|
5214
|
+
border-radius: 0.25em;
|
|
5215
|
+
padding: 0.1em 0.35em;
|
|
5216
|
+
}
|
|
5217
|
+
.jp-tiptap-content pre {
|
|
5218
|
+
background: color-mix(in oklch, var(--background) 60%, black);
|
|
5219
|
+
border-radius: 0.5em;
|
|
5220
|
+
padding: 1em 1.25em;
|
|
5221
|
+
overflow-x: auto;
|
|
5222
|
+
}
|
|
5223
|
+
.jp-tiptap-content pre code { background: none; padding: 0; }
|
|
5224
|
+
|
|
5225
|
+
.jp-tiptap-content ul { list-style-type: disc; padding-left: 1.625em; }
|
|
5226
|
+
.jp-tiptap-content ol { list-style-type: decimal; padding-left: 1.625em; }
|
|
5227
|
+
.jp-tiptap-content li { line-height: 1.7; margin-top: 0.25em; }
|
|
5228
|
+
.jp-tiptap-content li + li { margin-top: 0.25em; }
|
|
5229
|
+
|
|
5230
|
+
.jp-tiptap-content blockquote {
|
|
5231
|
+
border-left: 3px solid var(--border);
|
|
5232
|
+
padding-left: 1em;
|
|
5233
|
+
color: var(--muted-foreground);
|
|
5234
|
+
font-style: italic;
|
|
5235
|
+
}
|
|
5236
|
+
.jp-tiptap-content hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
|
|
5237
|
+
.jp-tiptap-content img { max-width: 100%; height: auto; border-radius: 0.5rem; }
|
|
5238
|
+
|
|
5239
|
+
/* ==========================================================================
|
|
5240
|
+
TIPTAP / PROSEMIRROR — Editor typography (studio view)
|
|
5241
|
+
========================================================================== */
|
|
5242
|
+
.jp-simple-editor .ProseMirror { outline: none; word-break: break-word; }
|
|
5243
|
+
.jp-simple-editor .ProseMirror > * + * { margin-top: 0.75em; }
|
|
5244
|
+
|
|
5245
|
+
.jp-simple-editor .ProseMirror h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
5246
|
+
.jp-simple-editor .ProseMirror h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
5247
|
+
.jp-simple-editor .ProseMirror h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
5248
|
+
.jp-simple-editor .ProseMirror h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
|
|
5249
|
+
|
|
5250
|
+
.jp-simple-editor .ProseMirror p { line-height: 1.7; }
|
|
5251
|
+
.jp-simple-editor .ProseMirror strong { font-weight: 700; }
|
|
5252
|
+
.jp-simple-editor .ProseMirror em { font-style: italic; }
|
|
5253
|
+
.jp-simple-editor .ProseMirror s { text-decoration: line-through; }
|
|
5254
|
+
|
|
5255
|
+
.jp-simple-editor .ProseMirror a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
|
|
5256
|
+
.jp-simple-editor .ProseMirror a:hover { opacity: 0.8; }
|
|
5257
|
+
|
|
5258
|
+
.jp-simple-editor .ProseMirror code {
|
|
5259
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
5260
|
+
font-size: 0.875em;
|
|
5261
|
+
background: color-mix(in oklch, var(--foreground) 8%, transparent);
|
|
5262
|
+
border-radius: 0.25em;
|
|
5263
|
+
padding: 0.1em 0.35em;
|
|
5264
|
+
}
|
|
5265
|
+
.jp-simple-editor .ProseMirror pre {
|
|
5266
|
+
background: color-mix(in oklch, var(--background) 60%, black);
|
|
5267
|
+
border-radius: 0.5em;
|
|
5268
|
+
padding: 1em 1.25em;
|
|
5269
|
+
overflow-x: auto;
|
|
5270
|
+
}
|
|
5271
|
+
.jp-simple-editor .ProseMirror pre code { background: none; padding: 0; }
|
|
5272
|
+
|
|
5273
|
+
.jp-simple-editor .ProseMirror ul { list-style-type: disc; padding-left: 1.625em; }
|
|
5274
|
+
.jp-simple-editor .ProseMirror ol { list-style-type: decimal; padding-left: 1.625em; }
|
|
5275
|
+
.jp-simple-editor .ProseMirror li { line-height: 1.7; margin-top: 0.25em; }
|
|
5276
|
+
.jp-simple-editor .ProseMirror li + li { margin-top: 0.25em; }
|
|
5277
|
+
|
|
5278
|
+
.jp-simple-editor .ProseMirror blockquote {
|
|
5279
|
+
border-left: 3px solid var(--border);
|
|
5280
|
+
padding-left: 1em;
|
|
5281
|
+
color: var(--muted-foreground);
|
|
5282
|
+
font-style: italic;
|
|
5283
|
+
}
|
|
5284
|
+
.jp-simple-editor .ProseMirror hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
|
|
5285
|
+
|
|
5286
|
+
.jp-simple-editor .ProseMirror img { max-width: 100%; height: auto; border-radius: 0.5rem; }
|
|
5287
|
+
.jp-simple-editor .ProseMirror img[data-uploading="true"] {
|
|
5288
|
+
opacity: 0.6;
|
|
5289
|
+
filter: grayscale(0.25);
|
|
5290
|
+
outline: 2px dashed rgb(59 130 246 / 0.7);
|
|
5291
|
+
outline-offset: 2px;
|
|
5292
|
+
}
|
|
5293
|
+
.jp-simple-editor .ProseMirror img[data-upload-error="true"] {
|
|
5294
|
+
outline: 2px solid rgb(239 68 68 / 0.8);
|
|
5295
|
+
outline-offset: 2px;
|
|
5296
|
+
}
|
|
5297
|
+
.jp-simple-editor .ProseMirror p.is-editor-empty:first-child::before {
|
|
5298
|
+
content: attr(data-placeholder);
|
|
5299
|
+
color: var(--muted-foreground);
|
|
5300
|
+
opacity: 0.5;
|
|
5301
|
+
pointer-events: none;
|
|
5302
|
+
float: left;
|
|
5303
|
+
height: 0;
|
|
5304
|
+
}
|
|
5305
|
+
```
|
|
5306
|
+
|
|
5307
|
+
---
|
|
5308
|
+
|
|
5309
|
+
## 4. Register in `src/lib/schemas.ts`
|
|
5310
|
+
|
|
5311
|
+
```ts
|
|
5312
|
+
import { TiptapSchema } from '@/components/tiptap';
|
|
5313
|
+
|
|
5314
|
+
export const SECTION_SCHEMAS = {
|
|
5315
|
+
// ... existing schemas
|
|
5316
|
+
'tiptap': TiptapSchema,
|
|
5317
|
+
} as const;
|
|
5318
|
+
```
|
|
5319
|
+
|
|
5320
|
+
---
|
|
5321
|
+
|
|
5322
|
+
## 5. Register in `src/lib/addSectionConfig.ts`
|
|
5323
|
+
|
|
5324
|
+
```ts
|
|
5325
|
+
const addableSectionTypes = [
|
|
5326
|
+
// ... existing types
|
|
5327
|
+
'tiptap',
|
|
5328
|
+
] as const;
|
|
5329
|
+
|
|
5330
|
+
const sectionTypeLabels = {
|
|
5331
|
+
// ... existing labels
|
|
5332
|
+
'tiptap': 'Tiptap Editorial',
|
|
5333
|
+
};
|
|
5334
|
+
|
|
5335
|
+
function getDefaultSectionData(type: string) {
|
|
5336
|
+
switch (type) {
|
|
5337
|
+
// ... existing cases
|
|
5338
|
+
case 'tiptap': return { content: '# Post title\n\nStart writing in Markdown...' };
|
|
5339
|
+
}
|
|
5340
|
+
}
|
|
5341
|
+
```
|
|
5342
|
+
|
|
5343
|
+
---
|
|
5344
|
+
|
|
5345
|
+
## 6. Register in `src/lib/ComponentRegistry.tsx`
|
|
5346
|
+
|
|
5347
|
+
```tsx
|
|
5348
|
+
import { Tiptap } from '@/components/tiptap';
|
|
5349
|
+
|
|
5350
|
+
export const ComponentRegistry = {
|
|
5351
|
+
// ... existing components
|
|
5352
|
+
'tiptap': Tiptap,
|
|
5353
|
+
};
|
|
5354
|
+
```
|
|
5355
|
+
|
|
5356
|
+
---
|
|
5357
|
+
|
|
5358
|
+
## 7. Register in `src/types.ts`
|
|
5359
|
+
|
|
5360
|
+
```ts
|
|
5361
|
+
import type { TiptapData, TiptapSettings } from '@/components/tiptap';
|
|
5362
|
+
|
|
5363
|
+
export type SectionComponentPropsMap = {
|
|
5364
|
+
// ... existing entries
|
|
5365
|
+
'tiptap': { data: TiptapData; settings?: TiptapSettings };
|
|
5366
|
+
};
|
|
5367
|
+
|
|
5368
|
+
declare module '@jsonpages/core' {
|
|
5369
|
+
export interface SectionDataRegistry {
|
|
5370
|
+
// ... existing entries
|
|
5371
|
+
'tiptap': TiptapData;
|
|
5372
|
+
}
|
|
5373
|
+
export interface SectionSettingsRegistry {
|
|
5374
|
+
// ... existing entries
|
|
5375
|
+
'tiptap': TiptapSettings;
|
|
5376
|
+
}
|
|
5377
|
+
}
|
|
5378
|
+
```
|
|
5379
|
+
|
|
5380
|
+
---
|
|
5381
|
+
|
|
5382
|
+
## Notes
|
|
5383
|
+
|
|
5384
|
+
- Typography uses tenant CSS variables (`--primary`, `--border`, `--muted-foreground`, `--font-mono`) — no hardcoded colors.
|
|
5385
|
+
- `@tailwindcss/typography` is **not** required; the CSS blocks above replace it.
|
|
5386
|
+
- The toolbar is admin-only (studio mode). In visitor mode, content is rendered via `ReactMarkdown`.
|
|
5387
|
+
- Underline is intentionally excluded: `tiptap-markdown` with `html: false` cannot round-trip `<u>` tags.
|
|
5388
|
+
|
|
5389
|
+
END_OF_FILE_CONTENT
|
|
5390
|
+
echo "Creating src/components/tiptap/View.tsx..."
|
|
5391
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/View.tsx"
|
|
5392
|
+
import React from 'react';
|
|
5393
|
+
import ReactMarkdown from 'react-markdown';
|
|
5394
|
+
import remarkGfm from 'remark-gfm';
|
|
5395
|
+
import rehypeSanitize from 'rehype-sanitize';
|
|
5396
|
+
import { useEditor, EditorContent } from '@tiptap/react';
|
|
5397
|
+
import type { Editor } from '@tiptap/react';
|
|
5398
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
5399
|
+
import Link from '@tiptap/extension-link';
|
|
5400
|
+
import Image from '@tiptap/extension-image';
|
|
5401
|
+
import { Markdown } from 'tiptap-markdown';
|
|
5402
|
+
import {
|
|
5403
|
+
Undo2, Redo2,
|
|
5404
|
+
List, ListOrdered,
|
|
5405
|
+
Bold, Italic, Strikethrough,
|
|
5406
|
+
Code2, Quote, SquareCode,
|
|
5407
|
+
Link2, Unlink2, ImagePlus, Eraser,
|
|
5408
|
+
} from 'lucide-react';
|
|
5409
|
+
import { STUDIO_EVENTS, useConfig, useStudio } from '@jsonpages/core';
|
|
5410
|
+
import type { TiptapData, TiptapSettings } from './types';
|
|
5411
|
+
|
|
5412
|
+
// ── UI primitives ─────────────────────────────────────────────────
|
|
5413
|
+
const Btn: React.FC<{
|
|
5414
|
+
active?: boolean; title: string; onClick: () => void; children: React.ReactNode;
|
|
5415
|
+
}> = ({ active = false, title, onClick, children }) => (
|
|
5416
|
+
<button
|
|
5417
|
+
type="button" title={title}
|
|
5418
|
+
onMouseDown={(e) => e.preventDefault()} onClick={onClick}
|
|
5419
|
+
className={[
|
|
5420
|
+
'inline-flex h-7 min-w-7 items-center justify-center rounded-md px-2 text-xs transition-colors',
|
|
5421
|
+
active ? 'bg-zinc-700/70 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200',
|
|
5422
|
+
].join(' ')}
|
|
5423
|
+
>{children}</button>
|
|
5424
|
+
);
|
|
5425
|
+
|
|
5426
|
+
const Sep: React.FC = () => (
|
|
5427
|
+
<span className="mx-0.5 h-5 w-px shrink-0 bg-zinc-800" aria-hidden />
|
|
5428
|
+
);
|
|
5429
|
+
|
|
5430
|
+
// ── Image extension with upload metadata ──────────────────────────
|
|
5431
|
+
const UploadableImage = Image.extend({
|
|
5432
|
+
addAttributes() {
|
|
5433
|
+
const bool = (attr: string) => ({
|
|
5434
|
+
default: false,
|
|
5435
|
+
parseHTML: (el: HTMLElement) => el.getAttribute(attr) === 'true',
|
|
5436
|
+
renderHTML: (attrs: Record<string, unknown>) =>
|
|
5437
|
+
attrs[attr.replace('data-', '').replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase())]
|
|
5438
|
+
? { [attr]: 'true' } : {},
|
|
5439
|
+
});
|
|
5440
|
+
return {
|
|
5441
|
+
...this.parent?.(),
|
|
5442
|
+
uploadId: {
|
|
5443
|
+
default: null,
|
|
5444
|
+
parseHTML: (el: HTMLElement) => el.getAttribute('data-upload-id'),
|
|
5445
|
+
renderHTML: (attrs: Record<string, unknown>) =>
|
|
5446
|
+
attrs.uploadId ? { 'data-upload-id': String(attrs.uploadId) } : {},
|
|
5447
|
+
},
|
|
5448
|
+
uploading: bool('data-uploading'),
|
|
5449
|
+
uploadError: bool('data-upload-error'),
|
|
5450
|
+
awaitingUpload: bool('data-awaiting-upload'),
|
|
5451
|
+
};
|
|
5452
|
+
},
|
|
5453
|
+
});
|
|
5454
|
+
|
|
5455
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
5456
|
+
const getMarkdown = (ed: Editor | null | undefined): string =>
|
|
5457
|
+
(ed?.storage as { markdown?: { getMarkdown?: () => string } } | undefined)
|
|
5458
|
+
?.markdown?.getMarkdown?.() ?? '';
|
|
5459
|
+
|
|
5460
|
+
const svg = (body: string) =>
|
|
5461
|
+
'data:image/svg+xml;utf8,' +
|
|
5462
|
+
encodeURIComponent(
|
|
5463
|
+
'<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'1200\' height=\'420\' viewBox=\'0 0 1200 420\'>' + body + '</svg>'
|
|
5464
|
+
);
|
|
5465
|
+
|
|
5466
|
+
const RECT = '<rect width=\'1200\' height=\'420\' fill=\'#090B14\' stroke=\'#3F3F46\' stroke-width=\'3\' stroke-dasharray=\'10 10\' rx=\'12\'/>';
|
|
5467
|
+
|
|
5468
|
+
const UPLOADING_SRC = svg(
|
|
5469
|
+
RECT + '<text x=\'600\' y=\'215\' font-family=\'Inter,Arial,sans-serif\' font-size=\'28\' font-weight=\'700\' fill=\'#A1A1AA\' text-anchor=\'middle\'>Uploading image\u2026</text>'
|
|
5470
|
+
);
|
|
5471
|
+
|
|
5472
|
+
const PICKER_SRC = svg(
|
|
5473
|
+
RECT +
|
|
5474
|
+
'<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>' +
|
|
5475
|
+
'<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>'
|
|
5476
|
+
);
|
|
5477
|
+
|
|
5478
|
+
const patchImage = (ed: Editor, uploadId: string, patch: Record<string, unknown>): boolean => {
|
|
5479
|
+
let pos: number | null = null;
|
|
5480
|
+
ed.state.doc.descendants(
|
|
5481
|
+
(node: { type: { name: string }; attrs?: Record<string, unknown> }, p: number) => {
|
|
5482
|
+
if (node.type.name === 'image' && node.attrs?.uploadId === uploadId) { pos = p; return false; }
|
|
5483
|
+
return true;
|
|
5484
|
+
}
|
|
5485
|
+
);
|
|
5486
|
+
if (pos == null) return false;
|
|
5487
|
+
const cur = ed.state.doc.nodeAt(pos);
|
|
5488
|
+
if (!cur) return false;
|
|
5489
|
+
ed.view.dispatch(ed.state.tr.setNodeMarkup(pos, undefined, { ...cur.attrs, ...patch }));
|
|
5490
|
+
return true;
|
|
5491
|
+
};
|
|
5492
|
+
|
|
5493
|
+
const EXTENSIONS = [
|
|
5494
|
+
StarterKit,
|
|
5495
|
+
Link.configure({ openOnClick: false, autolink: true }),
|
|
5496
|
+
UploadableImage,
|
|
5497
|
+
Markdown.configure({ html: false }),
|
|
5498
|
+
];
|
|
5499
|
+
|
|
5500
|
+
// ── Studio editor ─────────────────────────────────────────────────
|
|
5501
|
+
const StudioTiptapEditor: React.FC<{ data: TiptapData }> = ({ data }) => {
|
|
5502
|
+
const { assets } = useConfig();
|
|
5503
|
+
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
|
5504
|
+
const sectionRef = React.useRef<HTMLElement | null>(null);
|
|
5505
|
+
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
|
5506
|
+
const editorRef = React.useRef<Editor | null>(null);
|
|
5507
|
+
const pendingUploads = React.useRef<Map<string, Promise<void>>>(new Map());
|
|
5508
|
+
const pendingPickerId = React.useRef<string | null>(null);
|
|
5509
|
+
const latestMd = React.useRef<string>(data.content ?? '');
|
|
5510
|
+
const emittedMd = React.useRef<string>(data.content ?? '');
|
|
5511
|
+
const [linkOpen, setLinkOpen] = React.useState(false);
|
|
5512
|
+
const [linkUrl, setLinkUrl] = React.useState('');
|
|
5513
|
+
const linkInputRef = React.useRef<HTMLInputElement | null>(null);
|
|
5514
|
+
|
|
5515
|
+
const getSectionId = React.useCallback((): string | null => {
|
|
5516
|
+
const el = sectionRef.current ?? (hostRef.current?.closest('[data-section-id]') as HTMLElement | null);
|
|
5517
|
+
sectionRef.current = el;
|
|
5518
|
+
return el?.getAttribute('data-section-id') ?? null;
|
|
5519
|
+
}, []);
|
|
5520
|
+
|
|
5521
|
+
const emit = React.useCallback((markdown: string) => {
|
|
5522
|
+
latestMd.current = markdown;
|
|
5523
|
+
const sectionId = getSectionId();
|
|
5524
|
+
if (!sectionId) return;
|
|
5525
|
+
window.parent.postMessage({ type: STUDIO_EVENTS.INLINE_FIELD_UPDATE, sectionId, fieldKey: 'content', value: markdown }, window.location.origin);
|
|
5526
|
+
emittedMd.current = markdown;
|
|
5527
|
+
}, [getSectionId]);
|
|
5528
|
+
|
|
5529
|
+
const setFocusLock = React.useCallback((on: boolean) => {
|
|
5530
|
+
sectionRef.current?.classList.toggle('jp-editorial-focus', on);
|
|
5531
|
+
}, []);
|
|
5532
|
+
|
|
5533
|
+
const insertPlaceholder = React.useCallback((uploadId: string, src: string, awaitingUpload: boolean) => {
|
|
5534
|
+
const ed = editorRef.current;
|
|
5535
|
+
if (!ed) return;
|
|
5536
|
+
ed.chain().focus().setImage({ src, alt: 'upload-placeholder', title: awaitingUpload ? 'Click to upload' : 'Uploading\u2026', uploadId, uploading: !awaitingUpload, awaitingUpload, uploadError: false } as any).run();
|
|
5537
|
+
emit(getMarkdown(ed));
|
|
5538
|
+
}, [emit]);
|
|
5539
|
+
|
|
5540
|
+
const doUpload = React.useCallback(async (uploadId: string, file: File) => {
|
|
5541
|
+
const uploadFn = assets?.onAssetUpload;
|
|
5542
|
+
if (!uploadFn) return;
|
|
5543
|
+
const ed = editorRef.current;
|
|
5544
|
+
if (!ed) return;
|
|
5545
|
+
patchImage(ed, uploadId, { src: UPLOADING_SRC, alt: file.name, title: file.name, uploading: true, awaitingUpload: false, uploadError: false });
|
|
5546
|
+
const task = (async () => {
|
|
5547
|
+
try {
|
|
5548
|
+
const url = await uploadFn(file);
|
|
5549
|
+
const cur = editorRef.current;
|
|
5550
|
+
if (cur) { patchImage(cur, uploadId, { src: url, alt: file.name, title: file.name, uploadId: null, uploading: false, awaitingUpload: false, uploadError: false }); emit(getMarkdown(cur)); }
|
|
5551
|
+
} catch {
|
|
5552
|
+
const cur = editorRef.current;
|
|
5553
|
+
if (cur) { patchImage(cur, uploadId, { uploading: false, awaitingUpload: false, uploadError: true }); emit(getMarkdown(cur)); }
|
|
5554
|
+
} finally { pendingUploads.current.delete(uploadId); }
|
|
5555
|
+
})();
|
|
5556
|
+
pendingUploads.current.set(uploadId, task);
|
|
5557
|
+
}, [assets, emit]);
|
|
5558
|
+
|
|
5559
|
+
const uploadFile = React.useCallback(async (file: File) => {
|
|
5560
|
+
const id = crypto.randomUUID();
|
|
5561
|
+
insertPlaceholder(id, UPLOADING_SRC, false);
|
|
5562
|
+
await doUpload(id, file);
|
|
5563
|
+
}, [insertPlaceholder, doUpload]);
|
|
5564
|
+
|
|
5565
|
+
const editor = useEditor({
|
|
5566
|
+
extensions: EXTENSIONS,
|
|
5567
|
+
content: data.content ?? '',
|
|
5568
|
+
editorProps: { attributes: { class: 'min-h-[220px] p-4 outline-none' } },
|
|
5569
|
+
onUpdate({ editor: ed }) { emit(getMarkdown(ed)); },
|
|
5570
|
+
onFocus() { setFocusLock(true); },
|
|
5571
|
+
onBlur() {
|
|
5572
|
+
setTimeout(() => {
|
|
5573
|
+
if (!hostRef.current?.contains(document.activeElement)) setFocusLock(false);
|
|
5574
|
+
}, 100);
|
|
5575
|
+
},
|
|
5576
|
+
onCreate({ editor: ed }) {
|
|
5577
|
+
editorRef.current = ed;
|
|
5578
|
+
if (data.content) ed.commands.setContent(data.content);
|
|
5579
|
+
},
|
|
5580
|
+
onDestroy() { editorRef.current = null; },
|
|
5581
|
+
});
|
|
5582
|
+
|
|
5583
|
+
React.useEffect(() => {
|
|
5584
|
+
if (!editor || !data.content || data.content === latestMd.current) return;
|
|
5585
|
+
latestMd.current = data.content;
|
|
5586
|
+
emittedMd.current = data.content;
|
|
5587
|
+
editor.commands.setContent(data.content);
|
|
5588
|
+
}, [editor, data.content]);
|
|
5589
|
+
|
|
5590
|
+
React.useEffect(() => {
|
|
5591
|
+
const host = hostRef.current;
|
|
5592
|
+
if (!host) return;
|
|
5593
|
+
const onDrop = (e: DragEvent) => {
|
|
5594
|
+
e.preventDefault();
|
|
5595
|
+
const files = Array.from(e.dataTransfer?.files ?? []).filter(f => f.type.startsWith('image/'));
|
|
5596
|
+
files.forEach(f => void uploadFile(f));
|
|
5597
|
+
};
|
|
5598
|
+
const onPaste = (e: ClipboardEvent) => {
|
|
5599
|
+
const files = Array.from(e.clipboardData?.files ?? []).filter(f => f.type.startsWith('image/'));
|
|
5600
|
+
if (!files.length) return;
|
|
5601
|
+
e.preventDefault();
|
|
5602
|
+
files.forEach(f => void uploadFile(f));
|
|
5603
|
+
};
|
|
5604
|
+
host.addEventListener('drop', onDrop);
|
|
5605
|
+
host.addEventListener('paste', onPaste);
|
|
5606
|
+
return () => { host.removeEventListener('drop', onDrop); host.removeEventListener('paste', onPaste); };
|
|
5607
|
+
}, [uploadFile]);
|
|
5608
|
+
|
|
5609
|
+
const openLink = () => {
|
|
5610
|
+
const existing = editor?.getAttributes('link').href ?? '';
|
|
5611
|
+
setLinkUrl(existing);
|
|
5612
|
+
setLinkOpen(true);
|
|
5613
|
+
setTimeout(() => linkInputRef.current?.focus(), 50);
|
|
5614
|
+
};
|
|
5615
|
+
|
|
5616
|
+
const applyLink = () => {
|
|
5617
|
+
if (!editor) return;
|
|
5618
|
+
const url = linkUrl.trim();
|
|
5619
|
+
if (url) editor.chain().focus().setLink({ href: url }).run();
|
|
5620
|
+
else editor.chain().focus().unsetLink().run();
|
|
5621
|
+
setLinkOpen(false);
|
|
5622
|
+
};
|
|
5623
|
+
|
|
5624
|
+
const onFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
5625
|
+
const file = e.target.files?.[0];
|
|
5626
|
+
e.target.value = '';
|
|
5627
|
+
if (!file) return;
|
|
5628
|
+
const pickId = pendingPickerId.current;
|
|
5629
|
+
void (async () => {
|
|
5630
|
+
try {
|
|
5631
|
+
if (pickId) { await doUpload(pickId, file); pendingPickerId.current = null; }
|
|
5632
|
+
else { await uploadFile(file); }
|
|
5633
|
+
} catch { pendingPickerId.current = null; }
|
|
5634
|
+
})();
|
|
5635
|
+
};
|
|
5636
|
+
|
|
5637
|
+
const onPickImage = () => {
|
|
5638
|
+
if (pendingPickerId.current) return;
|
|
5639
|
+
const id = crypto.randomUUID();
|
|
5640
|
+
pendingPickerId.current = id;
|
|
5641
|
+
insertPlaceholder(id, PICKER_SRC, true);
|
|
5642
|
+
};
|
|
5643
|
+
|
|
5644
|
+
const isActive = (name: string, attrs?: Record<string, unknown>) => editor?.isActive(name, attrs) ?? false;
|
|
5645
|
+
|
|
5646
|
+
return (
|
|
5647
|
+
<div ref={hostRef} data-jp-field="content" className="space-y-2">
|
|
5648
|
+
{editor && (
|
|
5649
|
+
<div data-jp-ignore-select="true" className="sticky top-0 z-[65] border-b border-zinc-800 bg-zinc-950">
|
|
5650
|
+
<div className="flex flex-wrap items-center justify-center gap-1 p-2">
|
|
5651
|
+
<Btn title="Undo" onClick={() => editor.chain().focus().undo().run()}><Undo2 size={13} /></Btn>
|
|
5652
|
+
<Btn title="Redo" onClick={() => editor.chain().focus().redo().run()}><Redo2 size={13} /></Btn>
|
|
5653
|
+
<Sep />
|
|
5654
|
+
<Btn active={isActive('paragraph')} title="Paragraph" onClick={() => editor.chain().focus().setParagraph().run()}>P</Btn>
|
|
5655
|
+
<Btn active={isActive('heading', { level: 1 })} title="Heading 1" onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>H1</Btn>
|
|
5656
|
+
<Btn active={isActive('heading', { level: 2 })} title="Heading 2" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</Btn>
|
|
5657
|
+
<Btn active={isActive('heading', { level: 3 })} title="Heading 3" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</Btn>
|
|
5658
|
+
<Sep />
|
|
5659
|
+
<Btn active={isActive('bold')} title="Bold (Ctrl+B)" onClick={() => editor.chain().focus().toggleBold().run()}><Bold size={13} /></Btn>
|
|
5660
|
+
<Btn active={isActive('italic')} title="Italic (Ctrl+I)" onClick={() => editor.chain().focus().toggleItalic().run()}><Italic size={13} /></Btn>
|
|
5661
|
+
<Btn active={isActive('strike')} title="Strikethrough" onClick={() => editor.chain().focus().toggleStrike().run()}><Strikethrough size={13} /></Btn>
|
|
5662
|
+
<Btn active={isActive('code')} title="Inline code" onClick={() => editor.chain().focus().toggleCode().run()}><Code2 size={13} /></Btn>
|
|
5663
|
+
<Sep />
|
|
5664
|
+
<Btn active={isActive('bulletList')} title="Bullet list" onClick={() => editor.chain().focus().toggleBulletList().run()}><List size={13} /></Btn>
|
|
5665
|
+
<Btn active={isActive('orderedList')} title="Ordered list" onClick={() => editor.chain().focus().toggleOrderedList().run()}><ListOrdered size={13} /></Btn>
|
|
5666
|
+
<Btn active={isActive('blockquote')} title="Blockquote" onClick={() => editor.chain().focus().toggleBlockquote().run()}><Quote size={13} /></Btn>
|
|
5667
|
+
<Btn active={isActive('codeBlock')} title="Code block" onClick={() => editor.chain().focus().toggleCodeBlock().run()}><SquareCode size={13} /></Btn>
|
|
5668
|
+
<Sep />
|
|
5669
|
+
<Btn active={isActive('link') || linkOpen} title="Set link" onClick={openLink}><Link2 size={13} /></Btn>
|
|
5670
|
+
<Btn title="Remove link" onClick={() => editor.chain().focus().unsetLink().run()}><Unlink2 size={13} /></Btn>
|
|
5671
|
+
<Btn title="Insert image" onClick={onPickImage}><ImagePlus size={13} /></Btn>
|
|
5672
|
+
<Btn title="Clear formatting" onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}><Eraser size={13} /></Btn>
|
|
5673
|
+
</div>
|
|
5674
|
+
{linkOpen && (
|
|
5675
|
+
<div className="flex items-center gap-2 border-t border-zinc-700 px-2 py-1.5">
|
|
5676
|
+
<Link2 size={12} className="shrink-0 text-zinc-500" />
|
|
5677
|
+
<input ref={linkInputRef} type="url" value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)}
|
|
5678
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); applyLink(); } if (e.key === 'Escape') setLinkOpen(false); }}
|
|
5679
|
+
placeholder="https://example.com"
|
|
5680
|
+
className="min-w-0 flex-1 bg-transparent text-xs text-zinc-100 placeholder:text-zinc-500 outline-none" />
|
|
5681
|
+
<button type="button" onMouseDown={(e) => e.preventDefault()} onClick={applyLink} className="shrink-0 rounded px-2 py-0.5 text-xs bg-blue-600 hover:bg-blue-500 text-white transition-colors">Set</button>
|
|
5682
|
+
<button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => setLinkOpen(false)} className="shrink-0 rounded px-2 py-0.5 text-xs bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors">Cancel</button>
|
|
5683
|
+
</div>
|
|
5684
|
+
)}
|
|
5685
|
+
</div>
|
|
5686
|
+
)}
|
|
5687
|
+
<EditorContent editor={editor} className="jp-simple-editor" />
|
|
5688
|
+
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onFileSelected} />
|
|
5689
|
+
</div>
|
|
5690
|
+
);
|
|
5691
|
+
};
|
|
5692
|
+
|
|
5693
|
+
// ── Public view ───────────────────────────────────────────────────
|
|
5694
|
+
const PublicTiptapContent: React.FC<{ content: string }> = ({ content }) => (
|
|
5695
|
+
<article className="jp-tiptap-content" data-jp-field="content">
|
|
5696
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>
|
|
5697
|
+
{content}
|
|
5698
|
+
</ReactMarkdown>
|
|
5699
|
+
</article>
|
|
5700
|
+
);
|
|
5701
|
+
|
|
5702
|
+
// ── Export ────────────────────────────────────────────────────────
|
|
5703
|
+
export const Tiptap: React.FC<{ data: TiptapData; settings?: TiptapSettings }> = ({ data }) => {
|
|
5704
|
+
const { mode } = useStudio();
|
|
5705
|
+
return (
|
|
5706
|
+
<section
|
|
5707
|
+
style={{ '--local-bg': 'var(--background)', '--local-text': 'var(--foreground)' } as React.CSSProperties}
|
|
5708
|
+
className="relative z-0 w-full py-12 bg-[var(--local-bg)]"
|
|
5709
|
+
>
|
|
5710
|
+
<div className="max-w-3xl mx-auto px-6">
|
|
5711
|
+
{mode === 'studio' ? (
|
|
5712
|
+
<StudioTiptapEditor data={data} />
|
|
5713
|
+
) : (
|
|
5714
|
+
<PublicTiptapContent content={data.content ?? ''} />
|
|
5715
|
+
)}
|
|
5716
|
+
</div>
|
|
5717
|
+
</section>
|
|
5718
|
+
);
|
|
5719
|
+
};
|
|
5720
|
+
|
|
5721
|
+
END_OF_FILE_CONTENT
|
|
5722
|
+
echo "Creating src/components/tiptap/index.ts..."
|
|
5723
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/index.ts"
|
|
5724
|
+
export * from './View';
|
|
5725
|
+
export * from './schema';
|
|
5726
|
+
export * from './types';
|
|
5727
|
+
|
|
5728
|
+
END_OF_FILE_CONTENT
|
|
5729
|
+
echo "Creating src/components/tiptap/schema.ts..."
|
|
5730
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/schema.ts"
|
|
5731
|
+
import { z } from 'zod';
|
|
5732
|
+
import { BaseSectionData } from '@olonjs/core';
|
|
5733
|
+
|
|
5734
|
+
export const TiptapSchema = BaseSectionData.extend({
|
|
5735
|
+
content: z.string().default('').describe('ui:editorial-markdown'),
|
|
5736
|
+
});
|
|
5737
|
+
|
|
5738
|
+
export const TiptapSettingsSchema = z.object({});
|
|
5739
|
+
|
|
5740
|
+
END_OF_FILE_CONTENT
|
|
5741
|
+
echo "Creating src/components/tiptap/types.ts..."
|
|
5742
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/types.ts"
|
|
5743
|
+
import { z } from 'zod';
|
|
5744
|
+
import { TiptapSchema, TiptapSettingsSchema } from './schema';
|
|
5745
|
+
|
|
5746
|
+
export type TiptapData = z.infer<typeof TiptapSchema>;
|
|
5747
|
+
export type TiptapSettings = z.infer<typeof TiptapSettingsSchema>;
|
|
5748
|
+
|
|
5147
5749
|
END_OF_FILE_CONTENT
|
|
5148
5750
|
mkdir -p "src/components/ui"
|
|
5149
5751
|
echo "Creating src/components/ui/OlonMark.tsx..."
|
|
@@ -9880,6 +10482,17 @@ export function useOlonForms(options?: UseOlonFormsOptions): { states: Record<st
|
|
|
9880
10482
|
}
|
|
9881
10483
|
|
|
9882
10484
|
END_OF_FILE_CONTENT
|
|
10485
|
+
echo "Creating src/lib/utils.ts..."
|
|
10486
|
+
cat << 'END_OF_FILE_CONTENT' > "src/lib/utils.ts"
|
|
10487
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
10488
|
+
import { twMerge } from 'tailwind-merge';
|
|
10489
|
+
|
|
10490
|
+
export function cn(...inputs: ClassValue[]) {
|
|
10491
|
+
return twMerge(clsx(inputs));
|
|
10492
|
+
}
|
|
10493
|
+
|
|
10494
|
+
END_OF_FILE_CONTENT
|
|
10495
|
+
# SKIP: src/lib/utils.ts:Zone.Identifier is binary and cannot be embedded as text.
|
|
9883
10496
|
echo "Creating src/main.tsx..."
|
|
9884
10497
|
cat << 'END_OF_FILE_CONTENT' > "src/main.tsx"
|
|
9885
10498
|
import '@/types'; // TBP: load type augmentation from capsule-driven types
|
|
@@ -1674,7 +1674,7 @@ cat << 'END_OF_FILE_CONTENT' > "package.json"
|
|
|
1674
1674
|
"@tiptap/extension-link": "^2.11.5",
|
|
1675
1675
|
"@tiptap/react": "^2.11.5",
|
|
1676
1676
|
"@tiptap/starter-kit": "^2.11.5",
|
|
1677
|
-
"@olonjs/core": "^1.0.
|
|
1677
|
+
"@olonjs/core": "^1.0.124",
|
|
1678
1678
|
"class-variance-authority": "^0.7.1",
|
|
1679
1679
|
"clsx": "^2.1.1",
|
|
1680
1680
|
"lucide-react": "^0.474.0",
|
|
@@ -5144,6 +5144,608 @@ cat << 'END_OF_FILE_CONTENT' > "src/components/save-drawer/saverStyle.css"
|
|
|
5144
5144
|
}
|
|
5145
5145
|
|
|
5146
5146
|
|
|
5147
|
+
END_OF_FILE_CONTENT
|
|
5148
|
+
mkdir -p "src/components/tiptap"
|
|
5149
|
+
echo "Creating src/components/tiptap/INTEGRATION.md..."
|
|
5150
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/INTEGRATION.md"
|
|
5151
|
+
# Tiptap Editorial — Integration Guide
|
|
5152
|
+
|
|
5153
|
+
How to add the `tiptap` section to a new tenant.
|
|
5154
|
+
|
|
5155
|
+
---
|
|
5156
|
+
|
|
5157
|
+
## 1. Copy the component
|
|
5158
|
+
|
|
5159
|
+
Copy the entire folder into the new tenant:
|
|
5160
|
+
|
|
5161
|
+
```
|
|
5162
|
+
src/components/tiptap/
|
|
5163
|
+
index.ts
|
|
5164
|
+
types.ts
|
|
5165
|
+
View.tsx
|
|
5166
|
+
```
|
|
5167
|
+
|
|
5168
|
+
---
|
|
5169
|
+
|
|
5170
|
+
## 2. Install npm dependencies
|
|
5171
|
+
|
|
5172
|
+
Add to the tenant's `package.json` and run `npm install`:
|
|
5173
|
+
|
|
5174
|
+
```json
|
|
5175
|
+
"@tiptap/extension-image": "^2.11.5",
|
|
5176
|
+
"@tiptap/extension-link": "^2.11.5",
|
|
5177
|
+
"@tiptap/react": "^2.11.5",
|
|
5178
|
+
"@tiptap/starter-kit": "^2.11.5",
|
|
5179
|
+
"react-markdown": "^9.0.1",
|
|
5180
|
+
"rehype-sanitize": "^6.0.0",
|
|
5181
|
+
"remark-gfm": "^4.0.1",
|
|
5182
|
+
"tiptap-markdown": "^0.8.10"
|
|
5183
|
+
```
|
|
5184
|
+
|
|
5185
|
+
---
|
|
5186
|
+
|
|
5187
|
+
## 3. Add CSS to `src/index.css`
|
|
5188
|
+
|
|
5189
|
+
Two blocks are required — one for the public (visitor) view, one for the editor (studio) view.
|
|
5190
|
+
|
|
5191
|
+
```css
|
|
5192
|
+
/* ==========================================================================
|
|
5193
|
+
TIPTAP — Public content typography (visitor view)
|
|
5194
|
+
========================================================================== */
|
|
5195
|
+
.jp-tiptap-content > * + * { margin-top: 0.75em; }
|
|
5196
|
+
|
|
5197
|
+
.jp-tiptap-content h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
5198
|
+
.jp-tiptap-content h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
5199
|
+
.jp-tiptap-content h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
5200
|
+
.jp-tiptap-content h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
|
|
5201
|
+
|
|
5202
|
+
.jp-tiptap-content p { line-height: 1.7; }
|
|
5203
|
+
.jp-tiptap-content strong { font-weight: 700; }
|
|
5204
|
+
.jp-tiptap-content em { font-style: italic; }
|
|
5205
|
+
.jp-tiptap-content s { text-decoration: line-through; }
|
|
5206
|
+
|
|
5207
|
+
.jp-tiptap-content a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
|
|
5208
|
+
.jp-tiptap-content a:hover { opacity: 0.8; }
|
|
5209
|
+
|
|
5210
|
+
.jp-tiptap-content code {
|
|
5211
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
5212
|
+
font-size: 0.875em;
|
|
5213
|
+
background: color-mix(in oklch, var(--foreground) 8%, transparent);
|
|
5214
|
+
border-radius: 0.25em;
|
|
5215
|
+
padding: 0.1em 0.35em;
|
|
5216
|
+
}
|
|
5217
|
+
.jp-tiptap-content pre {
|
|
5218
|
+
background: color-mix(in oklch, var(--background) 60%, black);
|
|
5219
|
+
border-radius: 0.5em;
|
|
5220
|
+
padding: 1em 1.25em;
|
|
5221
|
+
overflow-x: auto;
|
|
5222
|
+
}
|
|
5223
|
+
.jp-tiptap-content pre code { background: none; padding: 0; }
|
|
5224
|
+
|
|
5225
|
+
.jp-tiptap-content ul { list-style-type: disc; padding-left: 1.625em; }
|
|
5226
|
+
.jp-tiptap-content ol { list-style-type: decimal; padding-left: 1.625em; }
|
|
5227
|
+
.jp-tiptap-content li { line-height: 1.7; margin-top: 0.25em; }
|
|
5228
|
+
.jp-tiptap-content li + li { margin-top: 0.25em; }
|
|
5229
|
+
|
|
5230
|
+
.jp-tiptap-content blockquote {
|
|
5231
|
+
border-left: 3px solid var(--border);
|
|
5232
|
+
padding-left: 1em;
|
|
5233
|
+
color: var(--muted-foreground);
|
|
5234
|
+
font-style: italic;
|
|
5235
|
+
}
|
|
5236
|
+
.jp-tiptap-content hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
|
|
5237
|
+
.jp-tiptap-content img { max-width: 100%; height: auto; border-radius: 0.5rem; }
|
|
5238
|
+
|
|
5239
|
+
/* ==========================================================================
|
|
5240
|
+
TIPTAP / PROSEMIRROR — Editor typography (studio view)
|
|
5241
|
+
========================================================================== */
|
|
5242
|
+
.jp-simple-editor .ProseMirror { outline: none; word-break: break-word; }
|
|
5243
|
+
.jp-simple-editor .ProseMirror > * + * { margin-top: 0.75em; }
|
|
5244
|
+
|
|
5245
|
+
.jp-simple-editor .ProseMirror h1 { font-size: 2em; font-weight: 700; line-height: 1.2; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
5246
|
+
.jp-simple-editor .ProseMirror h2 { font-size: 1.5em; font-weight: 700; line-height: 1.3; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
5247
|
+
.jp-simple-editor .ProseMirror h3 { font-size: 1.25em; font-weight: 600; line-height: 1.4; margin-top: 1.25em; margin-bottom: 0.25em; }
|
|
5248
|
+
.jp-simple-editor .ProseMirror h4 { font-size: 1em; font-weight: 600; line-height: 1.5; margin-top: 1em; margin-bottom: 0.25em; }
|
|
5249
|
+
|
|
5250
|
+
.jp-simple-editor .ProseMirror p { line-height: 1.7; }
|
|
5251
|
+
.jp-simple-editor .ProseMirror strong { font-weight: 700; }
|
|
5252
|
+
.jp-simple-editor .ProseMirror em { font-style: italic; }
|
|
5253
|
+
.jp-simple-editor .ProseMirror s { text-decoration: line-through; }
|
|
5254
|
+
|
|
5255
|
+
.jp-simple-editor .ProseMirror a { color: var(--primary); text-decoration: underline; text-underline-offset: 2px; }
|
|
5256
|
+
.jp-simple-editor .ProseMirror a:hover { opacity: 0.8; }
|
|
5257
|
+
|
|
5258
|
+
.jp-simple-editor .ProseMirror code {
|
|
5259
|
+
font-family: var(--font-mono, ui-monospace, monospace);
|
|
5260
|
+
font-size: 0.875em;
|
|
5261
|
+
background: color-mix(in oklch, var(--foreground) 8%, transparent);
|
|
5262
|
+
border-radius: 0.25em;
|
|
5263
|
+
padding: 0.1em 0.35em;
|
|
5264
|
+
}
|
|
5265
|
+
.jp-simple-editor .ProseMirror pre {
|
|
5266
|
+
background: color-mix(in oklch, var(--background) 60%, black);
|
|
5267
|
+
border-radius: 0.5em;
|
|
5268
|
+
padding: 1em 1.25em;
|
|
5269
|
+
overflow-x: auto;
|
|
5270
|
+
}
|
|
5271
|
+
.jp-simple-editor .ProseMirror pre code { background: none; padding: 0; }
|
|
5272
|
+
|
|
5273
|
+
.jp-simple-editor .ProseMirror ul { list-style-type: disc; padding-left: 1.625em; }
|
|
5274
|
+
.jp-simple-editor .ProseMirror ol { list-style-type: decimal; padding-left: 1.625em; }
|
|
5275
|
+
.jp-simple-editor .ProseMirror li { line-height: 1.7; margin-top: 0.25em; }
|
|
5276
|
+
.jp-simple-editor .ProseMirror li + li { margin-top: 0.25em; }
|
|
5277
|
+
|
|
5278
|
+
.jp-simple-editor .ProseMirror blockquote {
|
|
5279
|
+
border-left: 3px solid var(--border);
|
|
5280
|
+
padding-left: 1em;
|
|
5281
|
+
color: var(--muted-foreground);
|
|
5282
|
+
font-style: italic;
|
|
5283
|
+
}
|
|
5284
|
+
.jp-simple-editor .ProseMirror hr { border: none; border-top: 1px solid var(--border); margin: 1.5em 0; }
|
|
5285
|
+
|
|
5286
|
+
.jp-simple-editor .ProseMirror img { max-width: 100%; height: auto; border-radius: 0.5rem; }
|
|
5287
|
+
.jp-simple-editor .ProseMirror img[data-uploading="true"] {
|
|
5288
|
+
opacity: 0.6;
|
|
5289
|
+
filter: grayscale(0.25);
|
|
5290
|
+
outline: 2px dashed rgb(59 130 246 / 0.7);
|
|
5291
|
+
outline-offset: 2px;
|
|
5292
|
+
}
|
|
5293
|
+
.jp-simple-editor .ProseMirror img[data-upload-error="true"] {
|
|
5294
|
+
outline: 2px solid rgb(239 68 68 / 0.8);
|
|
5295
|
+
outline-offset: 2px;
|
|
5296
|
+
}
|
|
5297
|
+
.jp-simple-editor .ProseMirror p.is-editor-empty:first-child::before {
|
|
5298
|
+
content: attr(data-placeholder);
|
|
5299
|
+
color: var(--muted-foreground);
|
|
5300
|
+
opacity: 0.5;
|
|
5301
|
+
pointer-events: none;
|
|
5302
|
+
float: left;
|
|
5303
|
+
height: 0;
|
|
5304
|
+
}
|
|
5305
|
+
```
|
|
5306
|
+
|
|
5307
|
+
---
|
|
5308
|
+
|
|
5309
|
+
## 4. Register in `src/lib/schemas.ts`
|
|
5310
|
+
|
|
5311
|
+
```ts
|
|
5312
|
+
import { TiptapSchema } from '@/components/tiptap';
|
|
5313
|
+
|
|
5314
|
+
export const SECTION_SCHEMAS = {
|
|
5315
|
+
// ... existing schemas
|
|
5316
|
+
'tiptap': TiptapSchema,
|
|
5317
|
+
} as const;
|
|
5318
|
+
```
|
|
5319
|
+
|
|
5320
|
+
---
|
|
5321
|
+
|
|
5322
|
+
## 5. Register in `src/lib/addSectionConfig.ts`
|
|
5323
|
+
|
|
5324
|
+
```ts
|
|
5325
|
+
const addableSectionTypes = [
|
|
5326
|
+
// ... existing types
|
|
5327
|
+
'tiptap',
|
|
5328
|
+
] as const;
|
|
5329
|
+
|
|
5330
|
+
const sectionTypeLabels = {
|
|
5331
|
+
// ... existing labels
|
|
5332
|
+
'tiptap': 'Tiptap Editorial',
|
|
5333
|
+
};
|
|
5334
|
+
|
|
5335
|
+
function getDefaultSectionData(type: string) {
|
|
5336
|
+
switch (type) {
|
|
5337
|
+
// ... existing cases
|
|
5338
|
+
case 'tiptap': return { content: '# Post title\n\nStart writing in Markdown...' };
|
|
5339
|
+
}
|
|
5340
|
+
}
|
|
5341
|
+
```
|
|
5342
|
+
|
|
5343
|
+
---
|
|
5344
|
+
|
|
5345
|
+
## 6. Register in `src/lib/ComponentRegistry.tsx`
|
|
5346
|
+
|
|
5347
|
+
```tsx
|
|
5348
|
+
import { Tiptap } from '@/components/tiptap';
|
|
5349
|
+
|
|
5350
|
+
export const ComponentRegistry = {
|
|
5351
|
+
// ... existing components
|
|
5352
|
+
'tiptap': Tiptap,
|
|
5353
|
+
};
|
|
5354
|
+
```
|
|
5355
|
+
|
|
5356
|
+
---
|
|
5357
|
+
|
|
5358
|
+
## 7. Register in `src/types.ts`
|
|
5359
|
+
|
|
5360
|
+
```ts
|
|
5361
|
+
import type { TiptapData, TiptapSettings } from '@/components/tiptap';
|
|
5362
|
+
|
|
5363
|
+
export type SectionComponentPropsMap = {
|
|
5364
|
+
// ... existing entries
|
|
5365
|
+
'tiptap': { data: TiptapData; settings?: TiptapSettings };
|
|
5366
|
+
};
|
|
5367
|
+
|
|
5368
|
+
declare module '@jsonpages/core' {
|
|
5369
|
+
export interface SectionDataRegistry {
|
|
5370
|
+
// ... existing entries
|
|
5371
|
+
'tiptap': TiptapData;
|
|
5372
|
+
}
|
|
5373
|
+
export interface SectionSettingsRegistry {
|
|
5374
|
+
// ... existing entries
|
|
5375
|
+
'tiptap': TiptapSettings;
|
|
5376
|
+
}
|
|
5377
|
+
}
|
|
5378
|
+
```
|
|
5379
|
+
|
|
5380
|
+
---
|
|
5381
|
+
|
|
5382
|
+
## Notes
|
|
5383
|
+
|
|
5384
|
+
- Typography uses tenant CSS variables (`--primary`, `--border`, `--muted-foreground`, `--font-mono`) — no hardcoded colors.
|
|
5385
|
+
- `@tailwindcss/typography` is **not** required; the CSS blocks above replace it.
|
|
5386
|
+
- The toolbar is admin-only (studio mode). In visitor mode, content is rendered via `ReactMarkdown`.
|
|
5387
|
+
- Underline is intentionally excluded: `tiptap-markdown` with `html: false` cannot round-trip `<u>` tags.
|
|
5388
|
+
|
|
5389
|
+
END_OF_FILE_CONTENT
|
|
5390
|
+
echo "Creating src/components/tiptap/View.tsx..."
|
|
5391
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/View.tsx"
|
|
5392
|
+
import React from 'react';
|
|
5393
|
+
import ReactMarkdown from 'react-markdown';
|
|
5394
|
+
import remarkGfm from 'remark-gfm';
|
|
5395
|
+
import rehypeSanitize from 'rehype-sanitize';
|
|
5396
|
+
import { useEditor, EditorContent } from '@tiptap/react';
|
|
5397
|
+
import type { Editor } from '@tiptap/react';
|
|
5398
|
+
import StarterKit from '@tiptap/starter-kit';
|
|
5399
|
+
import Link from '@tiptap/extension-link';
|
|
5400
|
+
import Image from '@tiptap/extension-image';
|
|
5401
|
+
import { Markdown } from 'tiptap-markdown';
|
|
5402
|
+
import {
|
|
5403
|
+
Undo2, Redo2,
|
|
5404
|
+
List, ListOrdered,
|
|
5405
|
+
Bold, Italic, Strikethrough,
|
|
5406
|
+
Code2, Quote, SquareCode,
|
|
5407
|
+
Link2, Unlink2, ImagePlus, Eraser,
|
|
5408
|
+
} from 'lucide-react';
|
|
5409
|
+
import { STUDIO_EVENTS, useConfig, useStudio } from '@jsonpages/core';
|
|
5410
|
+
import type { TiptapData, TiptapSettings } from './types';
|
|
5411
|
+
|
|
5412
|
+
// ── UI primitives ─────────────────────────────────────────────────
|
|
5413
|
+
const Btn: React.FC<{
|
|
5414
|
+
active?: boolean; title: string; onClick: () => void; children: React.ReactNode;
|
|
5415
|
+
}> = ({ active = false, title, onClick, children }) => (
|
|
5416
|
+
<button
|
|
5417
|
+
type="button" title={title}
|
|
5418
|
+
onMouseDown={(e) => e.preventDefault()} onClick={onClick}
|
|
5419
|
+
className={[
|
|
5420
|
+
'inline-flex h-7 min-w-7 items-center justify-center rounded-md px-2 text-xs transition-colors',
|
|
5421
|
+
active ? 'bg-zinc-700/70 text-zinc-100' : 'text-zinc-400 hover:bg-zinc-800 hover:text-zinc-200',
|
|
5422
|
+
].join(' ')}
|
|
5423
|
+
>{children}</button>
|
|
5424
|
+
);
|
|
5425
|
+
|
|
5426
|
+
const Sep: React.FC = () => (
|
|
5427
|
+
<span className="mx-0.5 h-5 w-px shrink-0 bg-zinc-800" aria-hidden />
|
|
5428
|
+
);
|
|
5429
|
+
|
|
5430
|
+
// ── Image extension with upload metadata ──────────────────────────
|
|
5431
|
+
const UploadableImage = Image.extend({
|
|
5432
|
+
addAttributes() {
|
|
5433
|
+
const bool = (attr: string) => ({
|
|
5434
|
+
default: false,
|
|
5435
|
+
parseHTML: (el: HTMLElement) => el.getAttribute(attr) === 'true',
|
|
5436
|
+
renderHTML: (attrs: Record<string, unknown>) =>
|
|
5437
|
+
attrs[attr.replace('data-', '').replace(/-([a-z])/g, (_: string, c: string) => c.toUpperCase())]
|
|
5438
|
+
? { [attr]: 'true' } : {},
|
|
5439
|
+
});
|
|
5440
|
+
return {
|
|
5441
|
+
...this.parent?.(),
|
|
5442
|
+
uploadId: {
|
|
5443
|
+
default: null,
|
|
5444
|
+
parseHTML: (el: HTMLElement) => el.getAttribute('data-upload-id'),
|
|
5445
|
+
renderHTML: (attrs: Record<string, unknown>) =>
|
|
5446
|
+
attrs.uploadId ? { 'data-upload-id': String(attrs.uploadId) } : {},
|
|
5447
|
+
},
|
|
5448
|
+
uploading: bool('data-uploading'),
|
|
5449
|
+
uploadError: bool('data-upload-error'),
|
|
5450
|
+
awaitingUpload: bool('data-awaiting-upload'),
|
|
5451
|
+
};
|
|
5452
|
+
},
|
|
5453
|
+
});
|
|
5454
|
+
|
|
5455
|
+
// ── Helpers ───────────────────────────────────────────────────────
|
|
5456
|
+
const getMarkdown = (ed: Editor | null | undefined): string =>
|
|
5457
|
+
(ed?.storage as { markdown?: { getMarkdown?: () => string } } | undefined)
|
|
5458
|
+
?.markdown?.getMarkdown?.() ?? '';
|
|
5459
|
+
|
|
5460
|
+
const svg = (body: string) =>
|
|
5461
|
+
'data:image/svg+xml;utf8,' +
|
|
5462
|
+
encodeURIComponent(
|
|
5463
|
+
'<svg xmlns=\'http://www.w3.org/2000/svg\' width=\'1200\' height=\'420\' viewBox=\'0 0 1200 420\'>' + body + '</svg>'
|
|
5464
|
+
);
|
|
5465
|
+
|
|
5466
|
+
const RECT = '<rect width=\'1200\' height=\'420\' fill=\'#090B14\' stroke=\'#3F3F46\' stroke-width=\'3\' stroke-dasharray=\'10 10\' rx=\'12\'/>';
|
|
5467
|
+
|
|
5468
|
+
const UPLOADING_SRC = svg(
|
|
5469
|
+
RECT + '<text x=\'600\' y=\'215\' font-family=\'Inter,Arial,sans-serif\' font-size=\'28\' font-weight=\'700\' fill=\'#A1A1AA\' text-anchor=\'middle\'>Uploading image\u2026</text>'
|
|
5470
|
+
);
|
|
5471
|
+
|
|
5472
|
+
const PICKER_SRC = svg(
|
|
5473
|
+
RECT +
|
|
5474
|
+
'<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>' +
|
|
5475
|
+
'<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>'
|
|
5476
|
+
);
|
|
5477
|
+
|
|
5478
|
+
const patchImage = (ed: Editor, uploadId: string, patch: Record<string, unknown>): boolean => {
|
|
5479
|
+
let pos: number | null = null;
|
|
5480
|
+
ed.state.doc.descendants(
|
|
5481
|
+
(node: { type: { name: string }; attrs?: Record<string, unknown> }, p: number) => {
|
|
5482
|
+
if (node.type.name === 'image' && node.attrs?.uploadId === uploadId) { pos = p; return false; }
|
|
5483
|
+
return true;
|
|
5484
|
+
}
|
|
5485
|
+
);
|
|
5486
|
+
if (pos == null) return false;
|
|
5487
|
+
const cur = ed.state.doc.nodeAt(pos);
|
|
5488
|
+
if (!cur) return false;
|
|
5489
|
+
ed.view.dispatch(ed.state.tr.setNodeMarkup(pos, undefined, { ...cur.attrs, ...patch }));
|
|
5490
|
+
return true;
|
|
5491
|
+
};
|
|
5492
|
+
|
|
5493
|
+
const EXTENSIONS = [
|
|
5494
|
+
StarterKit,
|
|
5495
|
+
Link.configure({ openOnClick: false, autolink: true }),
|
|
5496
|
+
UploadableImage,
|
|
5497
|
+
Markdown.configure({ html: false }),
|
|
5498
|
+
];
|
|
5499
|
+
|
|
5500
|
+
// ── Studio editor ─────────────────────────────────────────────────
|
|
5501
|
+
const StudioTiptapEditor: React.FC<{ data: TiptapData }> = ({ data }) => {
|
|
5502
|
+
const { assets } = useConfig();
|
|
5503
|
+
const hostRef = React.useRef<HTMLDivElement | null>(null);
|
|
5504
|
+
const sectionRef = React.useRef<HTMLElement | null>(null);
|
|
5505
|
+
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
|
|
5506
|
+
const editorRef = React.useRef<Editor | null>(null);
|
|
5507
|
+
const pendingUploads = React.useRef<Map<string, Promise<void>>>(new Map());
|
|
5508
|
+
const pendingPickerId = React.useRef<string | null>(null);
|
|
5509
|
+
const latestMd = React.useRef<string>(data.content ?? '');
|
|
5510
|
+
const emittedMd = React.useRef<string>(data.content ?? '');
|
|
5511
|
+
const [linkOpen, setLinkOpen] = React.useState(false);
|
|
5512
|
+
const [linkUrl, setLinkUrl] = React.useState('');
|
|
5513
|
+
const linkInputRef = React.useRef<HTMLInputElement | null>(null);
|
|
5514
|
+
|
|
5515
|
+
const getSectionId = React.useCallback((): string | null => {
|
|
5516
|
+
const el = sectionRef.current ?? (hostRef.current?.closest('[data-section-id]') as HTMLElement | null);
|
|
5517
|
+
sectionRef.current = el;
|
|
5518
|
+
return el?.getAttribute('data-section-id') ?? null;
|
|
5519
|
+
}, []);
|
|
5520
|
+
|
|
5521
|
+
const emit = React.useCallback((markdown: string) => {
|
|
5522
|
+
latestMd.current = markdown;
|
|
5523
|
+
const sectionId = getSectionId();
|
|
5524
|
+
if (!sectionId) return;
|
|
5525
|
+
window.parent.postMessage({ type: STUDIO_EVENTS.INLINE_FIELD_UPDATE, sectionId, fieldKey: 'content', value: markdown }, window.location.origin);
|
|
5526
|
+
emittedMd.current = markdown;
|
|
5527
|
+
}, [getSectionId]);
|
|
5528
|
+
|
|
5529
|
+
const setFocusLock = React.useCallback((on: boolean) => {
|
|
5530
|
+
sectionRef.current?.classList.toggle('jp-editorial-focus', on);
|
|
5531
|
+
}, []);
|
|
5532
|
+
|
|
5533
|
+
const insertPlaceholder = React.useCallback((uploadId: string, src: string, awaitingUpload: boolean) => {
|
|
5534
|
+
const ed = editorRef.current;
|
|
5535
|
+
if (!ed) return;
|
|
5536
|
+
ed.chain().focus().setImage({ src, alt: 'upload-placeholder', title: awaitingUpload ? 'Click to upload' : 'Uploading\u2026', uploadId, uploading: !awaitingUpload, awaitingUpload, uploadError: false } as any).run();
|
|
5537
|
+
emit(getMarkdown(ed));
|
|
5538
|
+
}, [emit]);
|
|
5539
|
+
|
|
5540
|
+
const doUpload = React.useCallback(async (uploadId: string, file: File) => {
|
|
5541
|
+
const uploadFn = assets?.onAssetUpload;
|
|
5542
|
+
if (!uploadFn) return;
|
|
5543
|
+
const ed = editorRef.current;
|
|
5544
|
+
if (!ed) return;
|
|
5545
|
+
patchImage(ed, uploadId, { src: UPLOADING_SRC, alt: file.name, title: file.name, uploading: true, awaitingUpload: false, uploadError: false });
|
|
5546
|
+
const task = (async () => {
|
|
5547
|
+
try {
|
|
5548
|
+
const url = await uploadFn(file);
|
|
5549
|
+
const cur = editorRef.current;
|
|
5550
|
+
if (cur) { patchImage(cur, uploadId, { src: url, alt: file.name, title: file.name, uploadId: null, uploading: false, awaitingUpload: false, uploadError: false }); emit(getMarkdown(cur)); }
|
|
5551
|
+
} catch {
|
|
5552
|
+
const cur = editorRef.current;
|
|
5553
|
+
if (cur) { patchImage(cur, uploadId, { uploading: false, awaitingUpload: false, uploadError: true }); emit(getMarkdown(cur)); }
|
|
5554
|
+
} finally { pendingUploads.current.delete(uploadId); }
|
|
5555
|
+
})();
|
|
5556
|
+
pendingUploads.current.set(uploadId, task);
|
|
5557
|
+
}, [assets, emit]);
|
|
5558
|
+
|
|
5559
|
+
const uploadFile = React.useCallback(async (file: File) => {
|
|
5560
|
+
const id = crypto.randomUUID();
|
|
5561
|
+
insertPlaceholder(id, UPLOADING_SRC, false);
|
|
5562
|
+
await doUpload(id, file);
|
|
5563
|
+
}, [insertPlaceholder, doUpload]);
|
|
5564
|
+
|
|
5565
|
+
const editor = useEditor({
|
|
5566
|
+
extensions: EXTENSIONS,
|
|
5567
|
+
content: data.content ?? '',
|
|
5568
|
+
editorProps: { attributes: { class: 'min-h-[220px] p-4 outline-none' } },
|
|
5569
|
+
onUpdate({ editor: ed }) { emit(getMarkdown(ed)); },
|
|
5570
|
+
onFocus() { setFocusLock(true); },
|
|
5571
|
+
onBlur() {
|
|
5572
|
+
setTimeout(() => {
|
|
5573
|
+
if (!hostRef.current?.contains(document.activeElement)) setFocusLock(false);
|
|
5574
|
+
}, 100);
|
|
5575
|
+
},
|
|
5576
|
+
onCreate({ editor: ed }) {
|
|
5577
|
+
editorRef.current = ed;
|
|
5578
|
+
if (data.content) ed.commands.setContent(data.content);
|
|
5579
|
+
},
|
|
5580
|
+
onDestroy() { editorRef.current = null; },
|
|
5581
|
+
});
|
|
5582
|
+
|
|
5583
|
+
React.useEffect(() => {
|
|
5584
|
+
if (!editor || !data.content || data.content === latestMd.current) return;
|
|
5585
|
+
latestMd.current = data.content;
|
|
5586
|
+
emittedMd.current = data.content;
|
|
5587
|
+
editor.commands.setContent(data.content);
|
|
5588
|
+
}, [editor, data.content]);
|
|
5589
|
+
|
|
5590
|
+
React.useEffect(() => {
|
|
5591
|
+
const host = hostRef.current;
|
|
5592
|
+
if (!host) return;
|
|
5593
|
+
const onDrop = (e: DragEvent) => {
|
|
5594
|
+
e.preventDefault();
|
|
5595
|
+
const files = Array.from(e.dataTransfer?.files ?? []).filter(f => f.type.startsWith('image/'));
|
|
5596
|
+
files.forEach(f => void uploadFile(f));
|
|
5597
|
+
};
|
|
5598
|
+
const onPaste = (e: ClipboardEvent) => {
|
|
5599
|
+
const files = Array.from(e.clipboardData?.files ?? []).filter(f => f.type.startsWith('image/'));
|
|
5600
|
+
if (!files.length) return;
|
|
5601
|
+
e.preventDefault();
|
|
5602
|
+
files.forEach(f => void uploadFile(f));
|
|
5603
|
+
};
|
|
5604
|
+
host.addEventListener('drop', onDrop);
|
|
5605
|
+
host.addEventListener('paste', onPaste);
|
|
5606
|
+
return () => { host.removeEventListener('drop', onDrop); host.removeEventListener('paste', onPaste); };
|
|
5607
|
+
}, [uploadFile]);
|
|
5608
|
+
|
|
5609
|
+
const openLink = () => {
|
|
5610
|
+
const existing = editor?.getAttributes('link').href ?? '';
|
|
5611
|
+
setLinkUrl(existing);
|
|
5612
|
+
setLinkOpen(true);
|
|
5613
|
+
setTimeout(() => linkInputRef.current?.focus(), 50);
|
|
5614
|
+
};
|
|
5615
|
+
|
|
5616
|
+
const applyLink = () => {
|
|
5617
|
+
if (!editor) return;
|
|
5618
|
+
const url = linkUrl.trim();
|
|
5619
|
+
if (url) editor.chain().focus().setLink({ href: url }).run();
|
|
5620
|
+
else editor.chain().focus().unsetLink().run();
|
|
5621
|
+
setLinkOpen(false);
|
|
5622
|
+
};
|
|
5623
|
+
|
|
5624
|
+
const onFileSelected = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
5625
|
+
const file = e.target.files?.[0];
|
|
5626
|
+
e.target.value = '';
|
|
5627
|
+
if (!file) return;
|
|
5628
|
+
const pickId = pendingPickerId.current;
|
|
5629
|
+
void (async () => {
|
|
5630
|
+
try {
|
|
5631
|
+
if (pickId) { await doUpload(pickId, file); pendingPickerId.current = null; }
|
|
5632
|
+
else { await uploadFile(file); }
|
|
5633
|
+
} catch { pendingPickerId.current = null; }
|
|
5634
|
+
})();
|
|
5635
|
+
};
|
|
5636
|
+
|
|
5637
|
+
const onPickImage = () => {
|
|
5638
|
+
if (pendingPickerId.current) return;
|
|
5639
|
+
const id = crypto.randomUUID();
|
|
5640
|
+
pendingPickerId.current = id;
|
|
5641
|
+
insertPlaceholder(id, PICKER_SRC, true);
|
|
5642
|
+
};
|
|
5643
|
+
|
|
5644
|
+
const isActive = (name: string, attrs?: Record<string, unknown>) => editor?.isActive(name, attrs) ?? false;
|
|
5645
|
+
|
|
5646
|
+
return (
|
|
5647
|
+
<div ref={hostRef} data-jp-field="content" className="space-y-2">
|
|
5648
|
+
{editor && (
|
|
5649
|
+
<div data-jp-ignore-select="true" className="sticky top-0 z-[65] border-b border-zinc-800 bg-zinc-950">
|
|
5650
|
+
<div className="flex flex-wrap items-center justify-center gap-1 p-2">
|
|
5651
|
+
<Btn title="Undo" onClick={() => editor.chain().focus().undo().run()}><Undo2 size={13} /></Btn>
|
|
5652
|
+
<Btn title="Redo" onClick={() => editor.chain().focus().redo().run()}><Redo2 size={13} /></Btn>
|
|
5653
|
+
<Sep />
|
|
5654
|
+
<Btn active={isActive('paragraph')} title="Paragraph" onClick={() => editor.chain().focus().setParagraph().run()}>P</Btn>
|
|
5655
|
+
<Btn active={isActive('heading', { level: 1 })} title="Heading 1" onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}>H1</Btn>
|
|
5656
|
+
<Btn active={isActive('heading', { level: 2 })} title="Heading 2" onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}>H2</Btn>
|
|
5657
|
+
<Btn active={isActive('heading', { level: 3 })} title="Heading 3" onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}>H3</Btn>
|
|
5658
|
+
<Sep />
|
|
5659
|
+
<Btn active={isActive('bold')} title="Bold (Ctrl+B)" onClick={() => editor.chain().focus().toggleBold().run()}><Bold size={13} /></Btn>
|
|
5660
|
+
<Btn active={isActive('italic')} title="Italic (Ctrl+I)" onClick={() => editor.chain().focus().toggleItalic().run()}><Italic size={13} /></Btn>
|
|
5661
|
+
<Btn active={isActive('strike')} title="Strikethrough" onClick={() => editor.chain().focus().toggleStrike().run()}><Strikethrough size={13} /></Btn>
|
|
5662
|
+
<Btn active={isActive('code')} title="Inline code" onClick={() => editor.chain().focus().toggleCode().run()}><Code2 size={13} /></Btn>
|
|
5663
|
+
<Sep />
|
|
5664
|
+
<Btn active={isActive('bulletList')} title="Bullet list" onClick={() => editor.chain().focus().toggleBulletList().run()}><List size={13} /></Btn>
|
|
5665
|
+
<Btn active={isActive('orderedList')} title="Ordered list" onClick={() => editor.chain().focus().toggleOrderedList().run()}><ListOrdered size={13} /></Btn>
|
|
5666
|
+
<Btn active={isActive('blockquote')} title="Blockquote" onClick={() => editor.chain().focus().toggleBlockquote().run()}><Quote size={13} /></Btn>
|
|
5667
|
+
<Btn active={isActive('codeBlock')} title="Code block" onClick={() => editor.chain().focus().toggleCodeBlock().run()}><SquareCode size={13} /></Btn>
|
|
5668
|
+
<Sep />
|
|
5669
|
+
<Btn active={isActive('link') || linkOpen} title="Set link" onClick={openLink}><Link2 size={13} /></Btn>
|
|
5670
|
+
<Btn title="Remove link" onClick={() => editor.chain().focus().unsetLink().run()}><Unlink2 size={13} /></Btn>
|
|
5671
|
+
<Btn title="Insert image" onClick={onPickImage}><ImagePlus size={13} /></Btn>
|
|
5672
|
+
<Btn title="Clear formatting" onClick={() => editor.chain().focus().unsetAllMarks().clearNodes().run()}><Eraser size={13} /></Btn>
|
|
5673
|
+
</div>
|
|
5674
|
+
{linkOpen && (
|
|
5675
|
+
<div className="flex items-center gap-2 border-t border-zinc-700 px-2 py-1.5">
|
|
5676
|
+
<Link2 size={12} className="shrink-0 text-zinc-500" />
|
|
5677
|
+
<input ref={linkInputRef} type="url" value={linkUrl} onChange={(e) => setLinkUrl(e.target.value)}
|
|
5678
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); applyLink(); } if (e.key === 'Escape') setLinkOpen(false); }}
|
|
5679
|
+
placeholder="https://example.com"
|
|
5680
|
+
className="min-w-0 flex-1 bg-transparent text-xs text-zinc-100 placeholder:text-zinc-500 outline-none" />
|
|
5681
|
+
<button type="button" onMouseDown={(e) => e.preventDefault()} onClick={applyLink} className="shrink-0 rounded px-2 py-0.5 text-xs bg-blue-600 hover:bg-blue-500 text-white transition-colors">Set</button>
|
|
5682
|
+
<button type="button" onMouseDown={(e) => e.preventDefault()} onClick={() => setLinkOpen(false)} className="shrink-0 rounded px-2 py-0.5 text-xs bg-zinc-700 hover:bg-zinc-600 text-zinc-200 transition-colors">Cancel</button>
|
|
5683
|
+
</div>
|
|
5684
|
+
)}
|
|
5685
|
+
</div>
|
|
5686
|
+
)}
|
|
5687
|
+
<EditorContent editor={editor} className="jp-simple-editor" />
|
|
5688
|
+
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={onFileSelected} />
|
|
5689
|
+
</div>
|
|
5690
|
+
);
|
|
5691
|
+
};
|
|
5692
|
+
|
|
5693
|
+
// ── Public view ───────────────────────────────────────────────────
|
|
5694
|
+
const PublicTiptapContent: React.FC<{ content: string }> = ({ content }) => (
|
|
5695
|
+
<article className="jp-tiptap-content" data-jp-field="content">
|
|
5696
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeSanitize]}>
|
|
5697
|
+
{content}
|
|
5698
|
+
</ReactMarkdown>
|
|
5699
|
+
</article>
|
|
5700
|
+
);
|
|
5701
|
+
|
|
5702
|
+
// ── Export ────────────────────────────────────────────────────────
|
|
5703
|
+
export const Tiptap: React.FC<{ data: TiptapData; settings?: TiptapSettings }> = ({ data }) => {
|
|
5704
|
+
const { mode } = useStudio();
|
|
5705
|
+
return (
|
|
5706
|
+
<section
|
|
5707
|
+
style={{ '--local-bg': 'var(--background)', '--local-text': 'var(--foreground)' } as React.CSSProperties}
|
|
5708
|
+
className="relative z-0 w-full py-12 bg-[var(--local-bg)]"
|
|
5709
|
+
>
|
|
5710
|
+
<div className="max-w-3xl mx-auto px-6">
|
|
5711
|
+
{mode === 'studio' ? (
|
|
5712
|
+
<StudioTiptapEditor data={data} />
|
|
5713
|
+
) : (
|
|
5714
|
+
<PublicTiptapContent content={data.content ?? ''} />
|
|
5715
|
+
)}
|
|
5716
|
+
</div>
|
|
5717
|
+
</section>
|
|
5718
|
+
);
|
|
5719
|
+
};
|
|
5720
|
+
|
|
5721
|
+
END_OF_FILE_CONTENT
|
|
5722
|
+
echo "Creating src/components/tiptap/index.ts..."
|
|
5723
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/index.ts"
|
|
5724
|
+
export * from './View';
|
|
5725
|
+
export * from './schema';
|
|
5726
|
+
export * from './types';
|
|
5727
|
+
|
|
5728
|
+
END_OF_FILE_CONTENT
|
|
5729
|
+
echo "Creating src/components/tiptap/schema.ts..."
|
|
5730
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/schema.ts"
|
|
5731
|
+
import { z } from 'zod';
|
|
5732
|
+
import { BaseSectionData } from '@olonjs/core';
|
|
5733
|
+
|
|
5734
|
+
export const TiptapSchema = BaseSectionData.extend({
|
|
5735
|
+
content: z.string().default('').describe('ui:editorial-markdown'),
|
|
5736
|
+
});
|
|
5737
|
+
|
|
5738
|
+
export const TiptapSettingsSchema = z.object({});
|
|
5739
|
+
|
|
5740
|
+
END_OF_FILE_CONTENT
|
|
5741
|
+
echo "Creating src/components/tiptap/types.ts..."
|
|
5742
|
+
cat << 'END_OF_FILE_CONTENT' > "src/components/tiptap/types.ts"
|
|
5743
|
+
import { z } from 'zod';
|
|
5744
|
+
import { TiptapSchema, TiptapSettingsSchema } from './schema';
|
|
5745
|
+
|
|
5746
|
+
export type TiptapData = z.infer<typeof TiptapSchema>;
|
|
5747
|
+
export type TiptapSettings = z.infer<typeof TiptapSettingsSchema>;
|
|
5748
|
+
|
|
5147
5749
|
END_OF_FILE_CONTENT
|
|
5148
5750
|
mkdir -p "src/components/ui"
|
|
5149
5751
|
echo "Creating src/components/ui/OlonMark.tsx..."
|
|
@@ -9880,6 +10482,17 @@ export function useOlonForms(options?: UseOlonFormsOptions): { states: Record<st
|
|
|
9880
10482
|
}
|
|
9881
10483
|
|
|
9882
10484
|
END_OF_FILE_CONTENT
|
|
10485
|
+
echo "Creating src/lib/utils.ts..."
|
|
10486
|
+
cat << 'END_OF_FILE_CONTENT' > "src/lib/utils.ts"
|
|
10487
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
10488
|
+
import { twMerge } from 'tailwind-merge';
|
|
10489
|
+
|
|
10490
|
+
export function cn(...inputs: ClassValue[]) {
|
|
10491
|
+
return twMerge(clsx(inputs));
|
|
10492
|
+
}
|
|
10493
|
+
|
|
10494
|
+
END_OF_FILE_CONTENT
|
|
10495
|
+
# SKIP: src/lib/utils.ts:Zone.Identifier is binary and cannot be embedded as text.
|
|
9883
10496
|
echo "Creating src/main.tsx..."
|
|
9884
10497
|
cat << 'END_OF_FILE_CONTENT' > "src/main.tsx"
|
|
9885
10498
|
import '@/types'; // TBP: load type augmentation from capsule-driven types
|