@commonpub/editor 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +43 -10
  2. package/package.json +16 -4
  3. package/vue/components/BlockCanvas.vue +512 -0
  4. package/vue/components/BlockInsertZone.vue +86 -0
  5. package/vue/components/BlockPicker.vue +274 -0
  6. package/vue/components/BlockWrapper.vue +188 -0
  7. package/vue/components/EditorBlocks.vue +235 -0
  8. package/vue/components/EditorSection.vue +81 -0
  9. package/vue/components/EditorShell.vue +198 -0
  10. package/vue/components/EditorTagInput.vue +116 -0
  11. package/vue/components/EditorVisibility.vue +110 -0
  12. package/vue/components/blocks/BuildStepBlock.vue +103 -0
  13. package/vue/components/blocks/CalloutBlock.vue +123 -0
  14. package/vue/components/blocks/CheckpointBlock.vue +29 -0
  15. package/vue/components/blocks/CodeBlock.vue +178 -0
  16. package/vue/components/blocks/DividerBlock.vue +22 -0
  17. package/vue/components/blocks/DownloadsBlock.vue +43 -0
  18. package/vue/components/blocks/EmbedBlock.vue +22 -0
  19. package/vue/components/blocks/GalleryBlock.vue +235 -0
  20. package/vue/components/blocks/HeadingBlock.vue +97 -0
  21. package/vue/components/blocks/ImageBlock.vue +272 -0
  22. package/vue/components/blocks/MarkdownBlock.vue +259 -0
  23. package/vue/components/blocks/MathBlock.vue +39 -0
  24. package/vue/components/blocks/PartsListBlock.vue +354 -0
  25. package/vue/components/blocks/QuizBlock.vue +49 -0
  26. package/vue/components/blocks/QuoteBlock.vue +102 -0
  27. package/vue/components/blocks/SectionHeaderBlock.vue +132 -0
  28. package/vue/components/blocks/SliderBlock.vue +320 -0
  29. package/vue/components/blocks/TextBlock.vue +202 -0
  30. package/vue/components/blocks/ToolListBlock.vue +71 -0
  31. package/vue/components/blocks/VideoBlock.vue +24 -0
  32. package/vue/composables/useBlockEditor.ts +190 -0
  33. package/vue/index.ts +59 -0
  34. package/vue/provide.ts +28 -0
  35. package/vue/types.ts +40 -0
  36. package/vue/utils.ts +12 -0
package/README.md CHANGED
@@ -29,14 +29,47 @@ const editor = createCommonPubEditor({
29
29
 
30
30
  ### Block Types
31
31
 
32
- | Type | Extension | Description |
33
- | ---------- | ------------------- | ----------------------------------- |
34
- | `text` | `CommonPubText` | Rich text paragraph |
35
- | `heading` | `CommonPubHeading` | Heading (h1-h6) |
36
- | `code` | `CommonPubCodeBlock` | Syntax-highlighted code block |
37
- | `image` | `CommonPubImage` | Image with alt text and caption |
38
- | `quote` | `CommonPubQuote` | Block quote with attribution |
39
- | `callout` | `CommonPubCallout` | Callout box (info, warning, tip) |
32
+ 20 block types are available, covering rich text, media, and maker-specific content:
33
+
34
+ **Core blocks:**
35
+
36
+ | Type | Extension | Description |
37
+ | ---------- | ------------------------- | ----------------------------------- |
38
+ | `text` | `CommonPubText` | Rich text paragraph |
39
+ | `heading` | `CommonPubHeading` | Heading (h1-h6) |
40
+ | `code` | `CommonPubCodeBlock` | Syntax-highlighted code block |
41
+ | `image` | `CommonPubImage` | Image with alt text and caption |
42
+ | `quote` | `CommonPubQuote` | Block quote with attribution |
43
+ | `callout` | `CommonPubCallout` | Callout box (info, warning, tip) |
44
+
45
+ **Media & embed blocks:**
46
+
47
+ | Type | Extension | Description |
48
+ | ---------- | ------------------------- | ----------------------------------- |
49
+ | `gallery` | `CommonPubGallery` | Image gallery with captions |
50
+ | `video` | `CommonPubVideo` | Embedded video player |
51
+ | `embed` | `CommonPubEmbed` | External embed (iframe) |
52
+ | `markdown` | `CommonPubMarkdown` | Raw markdown block |
53
+ | `divider` | — | Horizontal divider |
54
+ | `sectionHeader` | — | Section header / separator |
55
+
56
+ **Maker-focused blocks:**
57
+
58
+ | Type | Extension | Description |
59
+ | ------------------ | ------------------------------- | ----------------------------------- |
60
+ | `partsList` | `CommonPubPartsList` | Bill of materials / parts list |
61
+ | `buildStep` | `CommonPubBuildStep` | Step-by-step build instructions |
62
+ | `toolList` | `CommonPubToolList` | Required tools list |
63
+ | `downloads` | `CommonPubDownloads` | Downloadable files (STL, Gerber, etc.) |
64
+
65
+ **Interactive blocks (explainers):**
66
+
67
+ | Type | Extension | Description |
68
+ | -------------------- | ------------------------------- | ----------------------------------- |
69
+ | `quiz` | `CommonPubQuiz` | Multiple-choice quiz |
70
+ | `interactiveSlider` | `CommonPubInteractiveSlider` | Interactive slider control |
71
+ | `checkpoint` | `CommonPubCheckpoint` | Progress checkpoint |
72
+ | `mathNotation` | `CommonPubMathNotation` | Mathematical notation |
40
73
 
41
74
  ### BlockTuple Format
42
75
 
@@ -76,7 +109,7 @@ Register custom block types or use the built-in ones:
76
109
  ```ts
77
110
  import { registerBlock, lookupBlock, listBlocks, registerCoreBlocks } from '@commonpub/editor';
78
111
 
79
- // Register all 6 core block types
112
+ // Register all 20 built-in block types
80
113
  registerCoreBlocks();
81
114
 
82
115
  // Register a custom block type
@@ -139,7 +172,7 @@ pnpm typecheck # Type-check without emitting
139
172
 
140
173
  - `@tiptap/core` + extensions: Editor framework
141
174
  - `@tiptap/pm`: ProseMirror bindings
142
- - `lowlight`: Syntax highlighting for code blocks
175
+ - `shiki`: Syntax highlighting for code blocks
143
176
  - `zod`: Block content validation
144
177
  - `@commonpub/config`: Feature flags
145
178
  - `@commonpub/schema`: Content type definitions
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/editor",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "type": "module",
5
5
  "description": "TipTap block editor with 18+ maker-focused extensions for CommonPub",
6
6
  "license": "AGPL-3.0-or-later",
@@ -31,10 +31,14 @@
31
31
  ".": {
32
32
  "types": "./dist/index.d.ts",
33
33
  "import": "./dist/index.js"
34
+ },
35
+ "./vue": {
36
+ "import": "./vue/index.ts"
34
37
  }
35
38
  },
36
39
  "files": [
37
40
  "dist/",
41
+ "vue/",
38
42
  "!dist/__tests__/",
39
43
  "README.md",
40
44
  "LICENSE"
@@ -46,10 +50,11 @@
46
50
  "remark-rehype": "^11.1.2",
47
51
  "unified": "^11.0.5",
48
52
  "zod": "^4.3.6",
49
- "@commonpub/config": "0.5.0",
50
- "@commonpub/schema": "0.5.0"
53
+ "@commonpub/config": "0.8.0",
54
+ "@commonpub/schema": "0.9.2"
51
55
  },
52
56
  "peerDependencies": {
57
+ "vue": "^3.4.0",
53
58
  "@tiptap/core": "^2.11.0",
54
59
  "@tiptap/extension-bold": "^2.11.0",
55
60
  "@tiptap/extension-bullet-list": "^2.11.0",
@@ -61,16 +66,23 @@
61
66
  "@tiptap/extension-link": "^2.11.0",
62
67
  "@tiptap/extension-list-item": "^2.11.0",
63
68
  "@tiptap/extension-ordered-list": "^2.11.0",
69
+ "@tiptap/extension-paragraph": "^2.11.0",
64
70
  "@tiptap/extension-placeholder": "^2.11.0",
65
71
  "@tiptap/extension-strike": "^2.11.0",
66
72
  "@tiptap/extension-text": "^2.11.0",
67
73
  "@tiptap/pm": "^2.11.0"
68
74
  },
75
+ "peerDependenciesMeta": {
76
+ "vue": {
77
+ "optional": true
78
+ }
79
+ },
69
80
  "devDependencies": {
70
81
  "@types/mdast": "^4.0.4",
71
82
  "jsdom": "^25.0.0",
72
83
  "typescript": "^5.7.0",
73
- "vitest": "^3.0.0"
84
+ "vitest": "^3.0.0",
85
+ "vue": "^3.5.0"
74
86
  },
75
87
  "scripts": {
76
88
  "build": "tsc",
@@ -0,0 +1,512 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * BlockCanvas — the main editor canvas that renders a block array
4
+ * with wrappers, insert zones, and a block picker.
5
+ *
6
+ * Supports:
7
+ * - Insert zones between blocks (click to open picker)
8
+ * - Slash command (/ in empty text block) opens picker inline
9
+ * - Drag-and-drop reordering via BlockWrapper
10
+ * - Floating text toolbar on selection (delegated to FloatingToolbar)
11
+ */
12
+ import { ref, inject, type Component } from 'vue';
13
+ import type { EditorBlock, BlockTypeGroup } from '../types.js';
14
+ import type { BlockEditor } from '../composables/useBlockEditor.js';
15
+ import { BLOCK_COMPONENTS_KEY, UPLOAD_HANDLER_KEY, SEARCH_PRODUCTS_KEY } from '../provide.js';
16
+
17
+ import BlockWrapper from './BlockWrapper.vue';
18
+ import BlockPicker from './BlockPicker.vue';
19
+ import BlockInsertZone from './BlockInsertZone.vue';
20
+
21
+ // Block components — static import map
22
+ import TextBlock from './blocks/TextBlock.vue';
23
+ import HeadingBlock from './blocks/HeadingBlock.vue';
24
+ import CodeBlock from './blocks/CodeBlock.vue';
25
+ import ImageBlock from './blocks/ImageBlock.vue';
26
+ import QuoteBlock from './blocks/QuoteBlock.vue';
27
+ import CalloutBlock from './blocks/CalloutBlock.vue';
28
+ import DividerBlock from './blocks/DividerBlock.vue';
29
+ import VideoBlock from './blocks/VideoBlock.vue';
30
+ import EmbedBlock from './blocks/EmbedBlock.vue';
31
+ import GalleryBlock from './blocks/GalleryBlock.vue';
32
+ import PartsListBlock from './blocks/PartsListBlock.vue';
33
+ import BuildStepBlock from './blocks/BuildStepBlock.vue';
34
+ import ToolListBlock from './blocks/ToolListBlock.vue';
35
+ import DownloadsBlock from './blocks/DownloadsBlock.vue';
36
+ import QuizBlock from './blocks/QuizBlock.vue';
37
+ import SliderBlock from './blocks/SliderBlock.vue';
38
+ import CheckpointBlock from './blocks/CheckpointBlock.vue';
39
+ import MathBlock from './blocks/MathBlock.vue';
40
+ import SectionHeaderBlock from './blocks/SectionHeaderBlock.vue';
41
+ import MarkdownBlock from './blocks/MarkdownBlock.vue';
42
+
43
+ const BLOCK_COMPONENTS: Record<string, Component> = {
44
+ paragraph: TextBlock,
45
+ text: TextBlock,
46
+ heading: HeadingBlock,
47
+ code: CodeBlock,
48
+ code_block: CodeBlock,
49
+ codeBlock: CodeBlock,
50
+ image: ImageBlock,
51
+ gallery: GalleryBlock,
52
+ quote: QuoteBlock,
53
+ blockquote: QuoteBlock,
54
+ callout: CalloutBlock,
55
+ divider: DividerBlock,
56
+ horizontal_rule: DividerBlock,
57
+ horizontalRule: DividerBlock,
58
+ video: VideoBlock,
59
+ embed: EmbedBlock,
60
+ partsList: PartsListBlock,
61
+ buildStep: BuildStepBlock,
62
+ toolList: ToolListBlock,
63
+ downloads: DownloadsBlock,
64
+ quiz: QuizBlock,
65
+ interactiveSlider: SliderBlock,
66
+ slider: SliderBlock,
67
+ checkpoint: CheckpointBlock,
68
+ mathNotation: MathBlock,
69
+ math: MathBlock,
70
+ bulletList: TextBlock,
71
+ orderedList: TextBlock,
72
+ sectionHeader: SectionHeaderBlock,
73
+ markdown: MarkdownBlock,
74
+ };
75
+
76
+ const props = defineProps<{
77
+ blockEditor: BlockEditor;
78
+ blockTypes: BlockTypeGroup[];
79
+ /** Upload handler passed through to ImageBlock/GalleryBlock */
80
+ onUpload?: (file: File) => Promise<{ url: string; width?: number | null; height?: number | null }>;
81
+ /** Product search handler passed through to PartsListBlock */
82
+ onSearchProducts?: (query: string) => Promise<Array<{ id: string; name: string; slug: string; description: string | null; category: string | null; imageUrl: string | null; purchaseUrl: string | null }>>;
83
+ }>();
84
+
85
+ // --- Provide/inject overrides ---
86
+ const componentOverrides = inject(BLOCK_COMPONENTS_KEY, {});
87
+ const injectedUpload = inject(UPLOAD_HANDLER_KEY, undefined);
88
+ const injectedSearch = inject(SEARCH_PRODUCTS_KEY, undefined);
89
+
90
+ /** Resolved upload handler — prop takes priority over injected */
91
+ const resolvedUpload = props.onUpload ?? injectedUpload;
92
+ const resolvedSearch = props.onSearchProducts ?? injectedSearch;
93
+
94
+ // --- Block picker state ---
95
+ const pickerVisible = ref(false);
96
+ const pickerInsertIndex = ref(0);
97
+ /** When non-null, slash command is replacing this block instead of inserting */
98
+ const slashCommandBlockId = ref<string | null>(null);
99
+
100
+ function openPicker(atIndex: number): void {
101
+ slashCommandBlockId.value = null;
102
+ pickerInsertIndex.value = atIndex;
103
+ pickerVisible.value = true;
104
+ }
105
+
106
+ function openSlashPicker(block: EditorBlock): void {
107
+ const idx = props.blockEditor.getBlockIndex(block.id);
108
+ if (idx === -1) return;
109
+ slashCommandBlockId.value = block.id;
110
+ pickerInsertIndex.value = idx;
111
+ pickerVisible.value = true;
112
+ }
113
+
114
+ function closePicker(): void {
115
+ pickerVisible.value = false;
116
+ slashCommandBlockId.value = null;
117
+ }
118
+
119
+ function onPickerSelect(type: string, attrs?: Record<string, unknown>): void {
120
+ if (slashCommandBlockId.value) {
121
+ props.blockEditor.replaceBlock(slashCommandBlockId.value, type, attrs);
122
+ } else {
123
+ props.blockEditor.addBlock(type, attrs, pickerInsertIndex.value);
124
+ }
125
+ closePicker();
126
+ }
127
+
128
+ // --- Floating toolbar state ---
129
+ const floatingToolbar = ref<{
130
+ visible: boolean;
131
+ top: number;
132
+ left: number;
133
+ blockId: string;
134
+ }>({ visible: false, top: 0, left: 0, blockId: '' });
135
+
136
+ function onSelectionChange(block: EditorBlock, hasSelection: boolean, rect: DOMRect | null): void {
137
+ if (hasSelection && rect) {
138
+ const toolbarWidth = 180;
139
+ const toolbarHeight = 44;
140
+ const rawTop = rect.top - toolbarHeight;
141
+ const rawLeft = rect.left + rect.width / 2;
142
+ floatingToolbar.value = {
143
+ visible: true,
144
+ top: Math.max(4, rawTop),
145
+ left: Math.max(toolbarWidth / 2 + 4, Math.min(rawLeft, window.innerWidth - toolbarWidth / 2 - 4)),
146
+ blockId: block.id,
147
+ };
148
+ } else {
149
+ floatingToolbar.value = { visible: false, top: 0, left: 0, blockId: '' };
150
+ }
151
+ }
152
+
153
+ // --- Floating toolbar commands ---
154
+ const blockRefs = ref<Map<string, { getEditor?: () => unknown }>>(new Map());
155
+
156
+ function setBlockRef(blockId: string, el: unknown): void {
157
+ if (el && typeof el === 'object' && 'getEditor' in el) {
158
+ blockRefs.value.set(blockId, el as { getEditor: () => unknown });
159
+ }
160
+ }
161
+
162
+ function getActiveEditor(): unknown {
163
+ const ref = blockRefs.value.get(floatingToolbar.value.blockId);
164
+ return ref?.getEditor?.() ?? null;
165
+ }
166
+
167
+ interface TipTapChainable {
168
+ focus: () => TipTapChainable;
169
+ toggleMark: (mark: string) => TipTapChainable;
170
+ unsetLink: () => TipTapChainable;
171
+ extendMarkRange: (type: string) => TipTapChainable;
172
+ setLink: (attrs: { href: string }) => TipTapChainable;
173
+ run: () => void;
174
+ }
175
+
176
+ interface TipTapEditor {
177
+ chain: () => TipTapChainable;
178
+ isActive: (name: string) => boolean;
179
+ }
180
+
181
+ function toggleMark(mark: string): void {
182
+ const editor = getActiveEditor() as TipTapEditor | null;
183
+ if (!editor) return;
184
+ editor.chain().focus().toggleMark(mark).run();
185
+ }
186
+
187
+ function toggleLink(): void {
188
+ const editor = getActiveEditor() as TipTapEditor | null;
189
+ if (!editor) return;
190
+ if (editor.isActive('link')) {
191
+ editor.chain().focus().unsetLink().run();
192
+ return;
193
+ }
194
+ const url = window.prompt('Enter URL:');
195
+ if (url) {
196
+ editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
197
+ }
198
+ }
199
+
200
+ // --- Empty state ---
201
+ function addFirstBlock(): void {
202
+ props.blockEditor.addBlock('paragraph');
203
+ }
204
+
205
+ // --- Block actions ---
206
+ function onSelect(block: EditorBlock): void {
207
+ props.blockEditor.selectBlock(block.id);
208
+ }
209
+
210
+ function onDelete(block: EditorBlock): void {
211
+ props.blockEditor.removeBlock(block.id);
212
+ }
213
+
214
+ function onDuplicate(block: EditorBlock): void {
215
+ props.blockEditor.duplicateBlock(block.id);
216
+ }
217
+
218
+ function onMoveUp(block: EditorBlock): void {
219
+ props.blockEditor.moveBlockUp(block.id);
220
+ }
221
+
222
+ function onMoveDown(block: EditorBlock): void {
223
+ props.blockEditor.moveBlockDown(block.id);
224
+ }
225
+
226
+ function onBlockUpdate(block: EditorBlock, content: Record<string, unknown>): void {
227
+ props.blockEditor.updateBlock(block.id, content);
228
+ }
229
+
230
+ function onEnterAtEnd(block: EditorBlock): void {
231
+ const idx = props.blockEditor.getBlockIndex(block.id);
232
+ if (idx === -1) return;
233
+ props.blockEditor.addBlock('paragraph', undefined, idx + 1);
234
+ }
235
+
236
+ function onBackspaceEmpty(block: EditorBlock): void {
237
+ const idx = props.blockEditor.getBlockIndex(block.id);
238
+ if (idx === -1) return;
239
+ if (props.blockEditor.blocks.value.length <= 1) return;
240
+ props.blockEditor.removeBlock(block.id);
241
+ }
242
+
243
+ // --- Drag and drop ---
244
+ const draggedBlockId = ref<string | null>(null);
245
+
246
+ function onDragStart(block: EditorBlock): void {
247
+ draggedBlockId.value = block.id;
248
+ }
249
+
250
+ function onDragEnd(): void {
251
+ draggedBlockId.value = null;
252
+ }
253
+
254
+ function onDrop(atIndex: number, event: DragEvent): void {
255
+ event.preventDefault();
256
+ if (!draggedBlockId.value) return;
257
+
258
+ const fromIndex = props.blockEditor.getBlockIndex(draggedBlockId.value);
259
+ if (fromIndex === -1) return;
260
+
261
+ const toIndex = atIndex > fromIndex ? atIndex - 1 : atIndex;
262
+ props.blockEditor.moveBlock(fromIndex, toIndex);
263
+ draggedBlockId.value = null;
264
+ }
265
+
266
+ // --- Click outside to deselect ---
267
+ function onCanvasClick(): void {
268
+ props.blockEditor.selectBlock(null);
269
+ floatingToolbar.value = { visible: false, top: 0, left: 0, blockId: '' };
270
+ }
271
+
272
+ // --- Resolve block component (injected overrides take priority) ---
273
+ function getBlockComponent(type: string): Component {
274
+ return componentOverrides[type] ?? BLOCK_COMPONENTS[type] ?? TextBlock;
275
+ }
276
+
277
+ /** Compute auto-numbered content for buildStep blocks */
278
+ function getBlockContent(block: EditorBlock, index: number): Record<string, unknown> {
279
+ if (block.type === 'buildStep') {
280
+ let stepNum = 1;
281
+ for (let i = 0; i < index; i++) {
282
+ if (props.blockEditor.blocks.value[i].type === 'buildStep') stepNum++;
283
+ }
284
+ return { ...block.content, stepNumber: stepNum };
285
+ }
286
+ return block.content;
287
+ }
288
+
289
+ /** Check if a block type uses the TextBlock component (supports slash commands) */
290
+ function isTextBlock(type: string): boolean {
291
+ return type === 'paragraph' || type === 'bulletList' || type === 'orderedList';
292
+ }
293
+
294
+ /** Check if a block type needs the onUpload prop */
295
+ function needsUpload(type: string): boolean {
296
+ return type === 'image' || type === 'gallery';
297
+ }
298
+
299
+ /** Check if a block type needs the onSearchProducts prop */
300
+ function needsSearch(type: string): boolean {
301
+ return type === 'partsList';
302
+ }
303
+ </script>
304
+
305
+ <template>
306
+ <div class="cpub-block-canvas" @click.self="onCanvasClick">
307
+ <!-- Page card wrapper -->
308
+ <div class="cpub-canvas-page">
309
+
310
+ <!-- Empty state -->
311
+ <div v-if="blockEditor.isEmpty.value" class="cpub-canvas-empty" @click="addFirstBlock">
312
+ <div class="cpub-canvas-empty-icon"><i class="fa-solid fa-pen-nib"></i></div>
313
+ <p class="cpub-canvas-empty-title">Start writing</p>
314
+ <p class="cpub-canvas-empty-desc">Click here to begin, or use the sidebar to add blocks</p>
315
+ </div>
316
+
317
+ <!-- Insert zone at top -->
318
+ <BlockInsertZone @insert="openPicker(0)" />
319
+ <!-- Picker at top position -->
320
+ <div v-if="pickerVisible && !slashCommandBlockId && pickerInsertIndex === 0" class="cpub-canvas-picker-anchor">
321
+ <BlockPicker :groups="blockTypes" :visible="true" @select="onPickerSelect" @close="closePicker" />
322
+ </div>
323
+
324
+ <!-- Block list -->
325
+ <template v-for="(block, index) in blockEditor.blocks.value" :key="block.id">
326
+ <BlockWrapper
327
+ :block="block"
328
+ :selected="blockEditor.selectedBlockId.value === block.id"
329
+ @select="onSelect(block)"
330
+ @delete="onDelete(block)"
331
+ @duplicate="onDuplicate(block)"
332
+ @move-up="onMoveUp(block)"
333
+ @move-down="onMoveDown(block)"
334
+ @drag-start="onDragStart(block)"
335
+ @drag-end="onDragEnd"
336
+ >
337
+ <component
338
+ :is="getBlockComponent(block.type)"
339
+ :ref="(el: unknown) => isTextBlock(block.type) && setBlockRef(block.id, el)"
340
+ :content="getBlockContent(block, index)"
341
+ v-bind="{
342
+ ...(needsUpload(block.type) && resolvedUpload ? { onUpload: resolvedUpload } : {}),
343
+ ...(needsSearch(block.type) && resolvedSearch ? { onSearchProducts: resolvedSearch } : {}),
344
+ }"
345
+ @update="(c: Record<string, unknown>) => onBlockUpdate(block, c)"
346
+ @slash-command="openSlashPicker(block)"
347
+ @selection-change="(has: boolean, rect: DOMRect | null) => onSelectionChange(block, has, rect)"
348
+ @enter-at-end="onEnterAtEnd(block)"
349
+ @backspace-empty="onBackspaceEmpty(block)"
350
+ />
351
+ </BlockWrapper>
352
+
353
+ <!-- Picker: slash command replaces this block -->
354
+ <div v-if="pickerVisible && slashCommandBlockId === block.id" class="cpub-canvas-picker-anchor">
355
+ <BlockPicker :groups="blockTypes" :visible="true" @select="onPickerSelect" @close="closePicker" />
356
+ </div>
357
+
358
+ <!-- Insert zone after each block -->
359
+ <BlockInsertZone
360
+ @insert="openPicker(index + 1)"
361
+ @drop="onDrop(index + 1, $event)"
362
+ />
363
+
364
+ <!-- Picker: insert zone triggered at this position -->
365
+ <div v-if="pickerVisible && !slashCommandBlockId && pickerInsertIndex === index + 1" class="cpub-canvas-picker-anchor">
366
+ <BlockPicker :groups="blockTypes" :visible="true" @select="onPickerSelect" @close="closePicker" />
367
+ </div>
368
+ </template>
369
+
370
+ </div><!-- /.cpub-canvas-page -->
371
+
372
+ <!-- Floating text toolbar -->
373
+ <Teleport to="body">
374
+ <div
375
+ v-if="floatingToolbar.visible"
376
+ class="cpub-floating-toolbar"
377
+ :style="{ top: floatingToolbar.top + 'px', left: floatingToolbar.left + 'px' }"
378
+ >
379
+ <button class="cpub-ft-btn" title="Bold" @mousedown.prevent="toggleMark('bold')">
380
+ <i class="fa-solid fa-bold"></i>
381
+ </button>
382
+ <button class="cpub-ft-btn" title="Italic" @mousedown.prevent="toggleMark('italic')">
383
+ <i class="fa-solid fa-italic"></i>
384
+ </button>
385
+ <button class="cpub-ft-btn" title="Strikethrough" @mousedown.prevent="toggleMark('strike')">
386
+ <i class="fa-solid fa-strikethrough"></i>
387
+ </button>
388
+ <button class="cpub-ft-btn" title="Inline code" @mousedown.prevent="toggleMark('code')">
389
+ <i class="fa-solid fa-code"></i>
390
+ </button>
391
+ <div class="cpub-ft-divider" />
392
+ <button class="cpub-ft-btn" title="Link" @mousedown.prevent="toggleLink">
393
+ <i class="fa-solid fa-link"></i>
394
+ </button>
395
+ </div>
396
+ </Teleport>
397
+ </div>
398
+ </template>
399
+
400
+ <style scoped>
401
+ .cpub-block-canvas {
402
+ padding: 36px 0 52px;
403
+ min-height: 300px;
404
+ position: relative;
405
+ display: flex;
406
+ flex-direction: column;
407
+ align-items: center;
408
+ }
409
+
410
+ .cpub-canvas-page {
411
+ width: 100%;
412
+ max-width: 680px;
413
+ background: var(--surface);
414
+ border: var(--border-width-default) solid var(--border);
415
+ box-shadow: var(--shadow-md);
416
+ padding: 44px 56px;
417
+ position: relative;
418
+ }
419
+
420
+ @media (max-width: 768px) {
421
+ .cpub-canvas-page {
422
+ border: none;
423
+ box-shadow: none;
424
+ padding: 16px;
425
+ }
426
+ .cpub-block-canvas {
427
+ padding: 8px 0 48px;
428
+ }
429
+ }
430
+
431
+ .cpub-canvas-empty {
432
+ text-align: center;
433
+ padding: 48px 24px 32px;
434
+ cursor: pointer;
435
+ border: 2px dashed transparent;
436
+ transition: border-color 0.15s, background 0.15s;
437
+ }
438
+
439
+ .cpub-canvas-empty:hover {
440
+ border-color: var(--accent-border);
441
+ background: var(--accent-bg);
442
+ }
443
+
444
+ .cpub-canvas-empty-icon {
445
+ font-size: 32px;
446
+ color: var(--text-faint);
447
+ margin-bottom: 12px;
448
+ }
449
+
450
+ .cpub-canvas-empty-title {
451
+ font-size: 16px;
452
+ font-weight: 600;
453
+ color: var(--text-dim);
454
+ margin-bottom: 6px;
455
+ }
456
+
457
+ .cpub-canvas-empty-desc {
458
+ font-size: 12px;
459
+ color: var(--text-faint);
460
+ }
461
+
462
+ .cpub-canvas-picker-anchor {
463
+ position: relative;
464
+ display: flex;
465
+ justify-content: center;
466
+ }
467
+ </style>
468
+
469
+ <!-- Floating toolbar styles (global since it's teleported) -->
470
+ <style>
471
+ .cpub-floating-toolbar {
472
+ --ft-surface: rgba(255, 255, 255, 0.15);
473
+ position: fixed;
474
+ z-index: 200;
475
+ display: flex;
476
+ align-items: center;
477
+ gap: 0;
478
+ background: var(--text, #1a1a1a);
479
+ border: var(--border-width-default) solid var(--border, #1a1a1a);
480
+ box-shadow: var(--shadow-md);
481
+ padding: 3px;
482
+ transform: translateX(-50%);
483
+ pointer-events: auto;
484
+ }
485
+
486
+ .cpub-ft-btn {
487
+ width: 30px;
488
+ height: 28px;
489
+ display: flex;
490
+ align-items: center;
491
+ justify-content: center;
492
+ background: transparent;
493
+ border: none;
494
+ color: var(--surface3, #eaeae7);
495
+ cursor: pointer;
496
+ font-size: 11px;
497
+ padding: 0;
498
+ transition: background 0.08s, color 0.08s;
499
+ }
500
+
501
+ .cpub-ft-btn:hover {
502
+ background: var(--ft-surface);
503
+ color: var(--surface, #fff);
504
+ }
505
+
506
+ .cpub-ft-divider {
507
+ width: 2px;
508
+ height: 18px;
509
+ background: var(--ft-surface);
510
+ margin: 0 2px;
511
+ }
512
+ </style>