@commonpub/editor 0.5.0 → 0.6.1
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/README.md +43 -10
- package/package.json +16 -4
- package/vue/components/BlockCanvas.vue +512 -0
- package/vue/components/BlockInsertZone.vue +86 -0
- package/vue/components/BlockPicker.vue +274 -0
- package/vue/components/BlockWrapper.vue +188 -0
- package/vue/components/EditorBlocks.vue +235 -0
- package/vue/components/EditorSection.vue +81 -0
- package/vue/components/EditorShell.vue +198 -0
- package/vue/components/EditorTagInput.vue +116 -0
- package/vue/components/EditorVisibility.vue +110 -0
- package/vue/components/blocks/BuildStepBlock.vue +103 -0
- package/vue/components/blocks/CalloutBlock.vue +123 -0
- package/vue/components/blocks/CheckpointBlock.vue +29 -0
- package/vue/components/blocks/CodeBlock.vue +178 -0
- package/vue/components/blocks/DividerBlock.vue +22 -0
- package/vue/components/blocks/DownloadsBlock.vue +43 -0
- package/vue/components/blocks/EmbedBlock.vue +22 -0
- package/vue/components/blocks/GalleryBlock.vue +235 -0
- package/vue/components/blocks/HeadingBlock.vue +97 -0
- package/vue/components/blocks/ImageBlock.vue +272 -0
- package/vue/components/blocks/MarkdownBlock.vue +259 -0
- package/vue/components/blocks/MathBlock.vue +39 -0
- package/vue/components/blocks/PartsListBlock.vue +354 -0
- package/vue/components/blocks/QuizBlock.vue +49 -0
- package/vue/components/blocks/QuoteBlock.vue +102 -0
- package/vue/components/blocks/SectionHeaderBlock.vue +132 -0
- package/vue/components/blocks/SliderBlock.vue +320 -0
- package/vue/components/blocks/TextBlock.vue +202 -0
- package/vue/components/blocks/ToolListBlock.vue +71 -0
- package/vue/components/blocks/VideoBlock.vue +24 -0
- package/vue/composables/useBlockEditor.ts +190 -0
- package/vue/index.ts +59 -0
- package/vue/provide.ts +28 -0
- package/vue/types.ts +40 -0
- 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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
|
37
|
-
|
|
|
38
|
-
| `
|
|
39
|
-
| `
|
|
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
|
|
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
|
-
- `
|
|
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.
|
|
3
|
+
"version": "0.6.1",
|
|
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/
|
|
50
|
-
"@commonpub/
|
|
53
|
+
"@commonpub/schema": "0.9.2",
|
|
54
|
+
"@commonpub/config": "0.8.0"
|
|
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>
|