@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.
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
@@ -0,0 +1,202 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Text/paragraph block — inline TipTap editor for rich text.
4
+ * Supports bold, italic, code, strike, link, lists.
5
+ * Detects "/" at start of empty block to trigger slash command menu.
6
+ */
7
+ import { ref, onMounted, onUnmounted, watch } from 'vue';
8
+ import { Editor } from '@tiptap/core';
9
+ import { Document } from '@tiptap/extension-document';
10
+ import { Text } from '@tiptap/extension-text';
11
+ import { Paragraph } from '@tiptap/extension-paragraph';
12
+ import { Bold } from '@tiptap/extension-bold';
13
+ import { Italic } from '@tiptap/extension-italic';
14
+ import { Code } from '@tiptap/extension-code';
15
+ import { Strike } from '@tiptap/extension-strike';
16
+ import { Link } from '@tiptap/extension-link';
17
+ import { History } from '@tiptap/extension-history';
18
+ import { Placeholder } from '@tiptap/extension-placeholder';
19
+ import { BulletList } from '@tiptap/extension-bullet-list';
20
+ import { OrderedList } from '@tiptap/extension-ordered-list';
21
+ import { ListItem } from '@tiptap/extension-list-item';
22
+ import { Extension } from '@tiptap/core';
23
+
24
+ const props = defineProps<{
25
+ content: Record<string, unknown>;
26
+ }>();
27
+
28
+ const emit = defineEmits<{
29
+ update: [content: Record<string, unknown>];
30
+ 'slash-command': [];
31
+ 'selection-change': [hasSelection: boolean, rect: DOMRect | null];
32
+ 'enter-at-end': [];
33
+ 'backspace-empty': [];
34
+ }>();
35
+
36
+ const editorEl = ref<HTMLElement | null>(null);
37
+ let editor: Editor | null = null;
38
+
39
+ onMounted(() => {
40
+ if (!editorEl.value) return;
41
+
42
+ editor = new Editor({
43
+ element: editorEl.value,
44
+ extensions: [
45
+ Document,
46
+ Text,
47
+ Paragraph,
48
+ Bold,
49
+ Italic,
50
+ Code,
51
+ Strike,
52
+ Link.configure({ openOnClick: false }),
53
+ History,
54
+ BulletList,
55
+ OrderedList,
56
+ ListItem,
57
+ Placeholder.configure({ placeholder: 'Type / for commands...' }),
58
+ Extension.create({
59
+ name: 'blockKeyboard',
60
+ addKeyboardShortcuts() {
61
+ return {
62
+ 'Enter': ({ editor: e }) => {
63
+ // If cursor is at the very end of the document and last node is an empty paragraph, create new block
64
+ const { state } = e;
65
+ const { $from } = state.selection;
66
+ const atEnd = $from.pos === state.doc.content.size - 1;
67
+ const isEmpty = state.doc.textContent.length === 0;
68
+ const lastNode = state.doc.lastChild;
69
+ const lastIsEmptyP = lastNode?.type.name === 'paragraph' && lastNode.textContent === '';
70
+
71
+ // If we're at end and the text has content, emit to create a new block below
72
+ if (atEnd && !isEmpty) {
73
+ emit('enter-at-end');
74
+ return true;
75
+ }
76
+ return false;
77
+ },
78
+ 'Backspace': ({ editor: e }) => {
79
+ // If block is completely empty, emit to delete this block
80
+ if (e.isEmpty) {
81
+ emit('backspace-empty');
82
+ return true;
83
+ }
84
+ return false;
85
+ },
86
+ };
87
+ },
88
+ }),
89
+ ],
90
+ content: (props.content.html as string) || '',
91
+ onUpdate: ({ editor: e }) => {
92
+ const html = e.getHTML();
93
+ const text = e.getText();
94
+
95
+ // Detect slash command: "/" typed at start of otherwise-empty block
96
+ if (text === '/') {
97
+ // Clear the slash character and trigger command menu
98
+ e.commands.clearContent(true);
99
+ emit('slash-command');
100
+ return;
101
+ }
102
+
103
+ emit('update', { html });
104
+ },
105
+ onSelectionUpdate: ({ editor: e }) => {
106
+ const { from, to } = e.state.selection;
107
+ const hasSelection = from !== to;
108
+
109
+ if (hasSelection) {
110
+ // Get the bounding rect of the selection for floating toolbar positioning
111
+ const view = e.view;
112
+ const start = view.coordsAtPos(from);
113
+ const end = view.coordsAtPos(to);
114
+ const rect = new DOMRect(
115
+ Math.min(start.left, end.left),
116
+ start.top,
117
+ Math.abs(end.right - start.left),
118
+ end.bottom - start.top,
119
+ );
120
+ emit('selection-change', true, rect);
121
+ } else {
122
+ emit('selection-change', false, null);
123
+ }
124
+ },
125
+ });
126
+ });
127
+
128
+ onUnmounted(() => {
129
+ editor?.destroy();
130
+ });
131
+
132
+ // Sync external content changes (e.g. undo at the block level)
133
+ watch(() => props.content.html, (newHtml) => {
134
+ if (editor && newHtml !== editor.getHTML()) {
135
+ editor.commands.setContent((newHtml as string) || '', false);
136
+ }
137
+ });
138
+
139
+ /** Expose the TipTap editor instance for the floating toolbar */
140
+ function getEditor(): Editor | null {
141
+ return editor;
142
+ }
143
+
144
+ defineExpose({ getEditor });
145
+ </script>
146
+
147
+ <template>
148
+ <div ref="editorEl" class="cpub-text-block" />
149
+ </template>
150
+
151
+ <style scoped>
152
+ .cpub-text-block {
153
+ font-size: 15px;
154
+ line-height: 1.75;
155
+ color: var(--text);
156
+ min-height: 1.75em;
157
+ }
158
+
159
+ .cpub-text-block :deep(.tiptap) {
160
+ outline: none;
161
+ min-height: 1.75em;
162
+ }
163
+
164
+ .cpub-text-block :deep(.tiptap p) {
165
+ margin-bottom: 0.5em;
166
+ }
167
+
168
+ .cpub-text-block :deep(.tiptap p:last-child) {
169
+ margin-bottom: 0;
170
+ }
171
+
172
+ .cpub-text-block :deep(.tiptap p.is-editor-empty:first-child::before) {
173
+ content: attr(data-placeholder);
174
+ color: var(--text-faint);
175
+ pointer-events: none;
176
+ float: left;
177
+ height: 0;
178
+ }
179
+
180
+ .cpub-text-block :deep(.tiptap strong) {
181
+ font-weight: 600;
182
+ }
183
+
184
+ .cpub-text-block :deep(.tiptap code) {
185
+ font-family: var(--font-mono);
186
+ font-size: 0.9em;
187
+ padding: 1px 4px;
188
+ background: var(--surface3);
189
+ border: var(--border-width-default) solid var(--border2);
190
+ }
191
+
192
+ .cpub-text-block :deep(.tiptap a) {
193
+ color: var(--accent);
194
+ text-decoration: underline;
195
+ }
196
+
197
+ .cpub-text-block :deep(.tiptap ul),
198
+ .cpub-text-block :deep(.tiptap ol) {
199
+ padding-left: 1.5em;
200
+ margin-bottom: 0.5em;
201
+ }
202
+ </style>
@@ -0,0 +1,71 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Tool list block — list of required tools with name and notes.
4
+ */
5
+ import { computed } from 'vue';
6
+ interface Tool { name: string; url?: string; required?: boolean; notes?: string; }
7
+
8
+ const props = defineProps<{
9
+ content: Record<string, unknown>;
10
+ }>();
11
+
12
+ const emit = defineEmits<{
13
+ update: [content: Record<string, unknown>];
14
+ }>();
15
+
16
+ const tools = computed(() => (props.content.tools as Tool[]) ?? []);
17
+
18
+ function updateTool(index: number, field: string, value: unknown): void {
19
+ const updated = [...tools.value];
20
+ updated[index] = { ...updated[index]!, [field]: value };
21
+ emit('update', { tools: updated });
22
+ }
23
+
24
+ function addTool(): void {
25
+ emit('update', { tools: [...tools.value, { name: '', required: true }] });
26
+ }
27
+
28
+ function removeTool(index: number): void {
29
+ emit('update', { tools: tools.value.filter((_: Tool, i: number) => i !== index) });
30
+ }
31
+ </script>
32
+
33
+ <template>
34
+ <div class="cpub-tools-block">
35
+ <div class="cpub-tools-header">
36
+ <i class="fa-solid fa-wrench cpub-tools-icon"></i>
37
+ <span class="cpub-tools-title">Tools Required</span>
38
+ <button class="cpub-tools-add" @click="addTool"><i class="fa-solid fa-plus"></i> Add tool</button>
39
+ </div>
40
+ <div class="cpub-tools-list">
41
+ <div v-for="(tool, i) in tools" :key="i" class="cpub-tool-item">
42
+ <input class="cpub-tool-name" type="text" :value="tool.name" placeholder="Tool name..." @input="updateTool(i, 'name', ($event.target as HTMLInputElement).value)" />
43
+ <input class="cpub-tool-note" type="text" :value="tool.notes ?? ''" placeholder="Notes..." @input="updateTool(i, 'notes', ($event.target as HTMLInputElement).value)" />
44
+ <button class="cpub-tool-remove" @click="removeTool(i)"><i class="fa-solid fa-xmark"></i></button>
45
+ </div>
46
+ <div v-if="tools.length === 0" class="cpub-tools-empty" @click="addTool">
47
+ <i class="fa-solid fa-plus"></i> Add your first tool
48
+ </div>
49
+ </div>
50
+ </div>
51
+ </template>
52
+
53
+ <style scoped>
54
+ .cpub-tools-block { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
55
+ .cpub-tools-header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: var(--border-width-default) solid var(--border2); background: var(--surface2); }
56
+ .cpub-tools-icon { font-size: 12px; color: var(--accent); }
57
+ .cpub-tools-title { font-size: 12px; font-weight: 600; flex: 1; }
58
+ .cpub-tools-add { font-family: var(--font-mono); font-size: 10px; padding: 3px 8px; background: transparent; border: var(--border-width-default) solid var(--border2); color: var(--text-dim); cursor: pointer; display: flex; align-items: center; gap: 4px; }
59
+ .cpub-tools-add:hover { border-color: var(--accent); color: var(--accent); }
60
+ .cpub-tools-list { padding: 8px; }
61
+ .cpub-tool-item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-bottom: var(--border-width-default) solid var(--border2); }
62
+ .cpub-tool-item:last-child { border-bottom: none; }
63
+ .cpub-tool-name { flex: 1; font-size: 12px; font-weight: 500; background: transparent; border: none; outline: none; color: var(--text); }
64
+ .cpub-tool-name::placeholder { color: var(--text-faint); }
65
+ .cpub-tool-note { flex: 1; font-size: 11px; background: transparent; border: none; outline: none; color: var(--text-dim); }
66
+ .cpub-tool-note::placeholder { color: var(--text-faint); }
67
+ .cpub-tool-remove { background: none; border: none; color: var(--text-faint); cursor: pointer; font-size: 10px; }
68
+ .cpub-tool-remove:hover { color: var(--red); }
69
+ .cpub-tools-empty { padding: 20px; text-align: center; font-size: 12px; color: var(--text-faint); cursor: pointer; }
70
+ .cpub-tools-empty:hover { color: var(--accent); background: var(--accent-bg); }
71
+ </style>
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+
4
+ const props = defineProps<{ content: Record<string, unknown> }>();
5
+ const emit = defineEmits<{ update: [content: Record<string, unknown>] }>();
6
+ const url = computed(() => (props.content.url as string) ?? '');
7
+ function updateField(field: string, value: string): void { emit('update', { ...props.content, [field]: value }); }
8
+ </script>
9
+ <template>
10
+ <div class="cpub-video-block">
11
+ <div class="cpub-video-header"><i class="fa-solid fa-film"></i> Video Embed</div>
12
+ <input class="cpub-video-url" type="url" :value="url" placeholder="Paste YouTube or Vimeo URL..." @input="updateField('url', ($event.target as HTMLInputElement).value)" />
13
+ <div v-if="url" class="cpub-video-preview"><i class="fa-solid fa-play"></i> {{ url }}</div>
14
+ </div>
15
+ </template>
16
+ <style scoped>
17
+ .cpub-video-block { border: var(--border-width-default) solid var(--border2); background: var(--surface); }
18
+ .cpub-video-header { padding: 8px 12px; font-size: 12px; font-weight: 600; background: var(--surface2); border-bottom: var(--border-width-default) solid var(--border2); display: flex; align-items: center; gap: 8px; }
19
+ .cpub-video-header i { color: var(--accent); }
20
+ .cpub-video-url { width: 100%; padding: 8px 12px; font-size: 12px; background: transparent; border: none; border-bottom: var(--border-width-default) solid var(--border2); color: var(--text); outline: none; }
21
+ .cpub-video-url:focus { border-bottom-color: var(--accent); }
22
+ .cpub-video-url::placeholder { color: var(--text-faint); }
23
+ .cpub-video-preview { padding: 32px; text-align: center; font-size: 12px; color: var(--text-dim); background: var(--text); color: var(--surface); display: flex; align-items: center; justify-content: center; gap: 8px; }
24
+ </style>
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Block editor composable — manages an array of content blocks with full CRUD operations.
3
+ * Blocks are stored as { id, type, content } and serialized to/from BlockTuple format for persistence.
4
+ *
5
+ * This is the canonical location — replaces duplicates in layer and explainer.
6
+ */
7
+ import { ref, computed, readonly, type Ref } from 'vue';
8
+ import type { BlockTuple } from '@commonpub/editor';
9
+ import type { EditorBlock, BlockEditorOptions } from '../types.js';
10
+
11
+ /** Default content values when creating a new block of each type */
12
+ const BLOCK_DEFAULTS: Record<string, () => Record<string, unknown>> = {
13
+ paragraph: () => ({ html: '' }),
14
+ heading: () => ({ text: '', level: 2 }),
15
+ code_block: () => ({ code: '', language: '', filename: '' }),
16
+ image: () => ({ src: '', alt: '', caption: '' }),
17
+ blockquote: () => ({ html: '', attribution: '' }),
18
+ callout: () => ({ html: '', variant: 'info' }),
19
+ gallery: () => ({ images: [] }),
20
+ video: () => ({ url: '', platform: 'youtube', caption: '' }),
21
+ embed: () => ({ url: '', type: 'generic', html: '' }),
22
+ horizontal_rule: () => ({}),
23
+ partsList: () => ({ parts: [] }),
24
+ buildStep: () => ({ stepNumber: 1, instructions: '', image: '', time: '' }),
25
+ toolList: () => ({ tools: [] }),
26
+ downloads: () => ({ files: [] }),
27
+ quiz: () => ({ question: '', options: [], feedback: '' }),
28
+ interactiveSlider: () => ({ label: '', min: 0, max: 100, step: 1, defaultValue: 50, states: [] }),
29
+ checkpoint: () => ({ message: '' }),
30
+ mathNotation: () => ({ expression: '', display: false }),
31
+ bulletList: () => ({ html: '' }),
32
+ orderedList: () => ({ html: '' }),
33
+ sectionHeader: () => ({ tag: '', title: '', body: '' }),
34
+ markdown: () => ({ content: '' }),
35
+ };
36
+
37
+ function generateId(): string {
38
+ return `blk-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
39
+ }
40
+
41
+ export function useBlockEditor(initialBlocks?: BlockTuple[], options?: BlockEditorOptions) {
42
+ const blocks = ref<EditorBlock[]>([]);
43
+ const selectedBlockId = ref<string | null>(null);
44
+
45
+ // Merge custom defaults with built-in defaults
46
+ const defaults = options?.blockDefaults
47
+ ? { ...BLOCK_DEFAULTS, ...options.blockDefaults }
48
+ : BLOCK_DEFAULTS;
49
+
50
+ // --- Init from BlockTuples ---
51
+ function fromBlockTuples(tuples: BlockTuple[]): void {
52
+ blocks.value = tuples.map(([type, content]) => ({
53
+ id: generateId(),
54
+ type,
55
+ content: { ...content },
56
+ }));
57
+ }
58
+
59
+ if (initialBlocks && initialBlocks.length > 0) {
60
+ fromBlockTuples(initialBlocks);
61
+ }
62
+
63
+ // --- Serialize back to BlockTuples ---
64
+ function toBlockTuples(): BlockTuple[] {
65
+ return blocks.value.map((b) => [b.type, { ...b.content }]);
66
+ }
67
+
68
+ // --- Mutations ---
69
+
70
+ function addBlock(type: string, attrs?: Record<string, unknown>, atIndex?: number): string {
71
+ const blockDefaults = defaults[type]?.() ?? {};
72
+ const block: EditorBlock = {
73
+ id: generateId(),
74
+ type,
75
+ content: { ...blockDefaults, ...attrs },
76
+ };
77
+
78
+ if (atIndex !== undefined && atIndex >= 0 && atIndex <= blocks.value.length) {
79
+ blocks.value.splice(atIndex, 0, block);
80
+ } else {
81
+ blocks.value.push(block);
82
+ }
83
+
84
+ selectedBlockId.value = block.id;
85
+ return block.id;
86
+ }
87
+
88
+ function replaceBlock(id: string, newType: string, attrs?: Record<string, unknown>): string {
89
+ const idx = blocks.value.findIndex((b) => b.id === id);
90
+ if (idx === -1) return addBlock(newType, attrs);
91
+
92
+ const blockDefaults = defaults[newType]?.() ?? {};
93
+ const newBlock: EditorBlock = {
94
+ id: generateId(),
95
+ type: newType,
96
+ content: { ...blockDefaults, ...attrs },
97
+ };
98
+
99
+ blocks.value.splice(idx, 1, newBlock);
100
+ selectedBlockId.value = newBlock.id;
101
+ return newBlock.id;
102
+ }
103
+
104
+ function removeBlock(id: string): void {
105
+ const idx = blocks.value.findIndex((b) => b.id === id);
106
+ if (idx === -1) return;
107
+ blocks.value.splice(idx, 1);
108
+ if (selectedBlockId.value === id) {
109
+ selectedBlockId.value = null;
110
+ }
111
+ }
112
+
113
+ function clearBlocks(): void {
114
+ blocks.value.splice(0, blocks.value.length);
115
+ selectedBlockId.value = null;
116
+ }
117
+
118
+ function updateBlock(id: string, content: Record<string, unknown>): void {
119
+ const block = blocks.value.find((b) => b.id === id);
120
+ if (block) {
121
+ block.content = { ...block.content, ...content };
122
+ }
123
+ }
124
+
125
+ function moveBlock(fromIndex: number, toIndex: number): void {
126
+ if (fromIndex < 0 || fromIndex >= blocks.value.length) return;
127
+ if (toIndex < 0 || toIndex >= blocks.value.length) return;
128
+ const [moved] = blocks.value.splice(fromIndex, 1);
129
+ blocks.value.splice(toIndex, 0, moved!);
130
+ }
131
+
132
+ function moveBlockUp(id: string): void {
133
+ const idx = blocks.value.findIndex((b) => b.id === id);
134
+ if (idx > 0) moveBlock(idx, idx - 1);
135
+ }
136
+
137
+ function moveBlockDown(id: string): void {
138
+ const idx = blocks.value.findIndex((b) => b.id === id);
139
+ if (idx < blocks.value.length - 1) moveBlock(idx, idx + 1);
140
+ }
141
+
142
+ function duplicateBlock(id: string): void {
143
+ const idx = blocks.value.findIndex((b) => b.id === id);
144
+ if (idx === -1) return;
145
+ const original = blocks.value[idx]!;
146
+ const clone: EditorBlock = {
147
+ id: generateId(),
148
+ type: original.type,
149
+ content: JSON.parse(JSON.stringify(original.content)),
150
+ };
151
+ blocks.value.splice(idx + 1, 0, clone);
152
+ selectedBlockId.value = clone.id;
153
+ }
154
+
155
+ function selectBlock(id: string | null): void {
156
+ selectedBlockId.value = id;
157
+ }
158
+
159
+ function getBlockIndex(id: string): number {
160
+ return blocks.value.findIndex((b) => b.id === id);
161
+ }
162
+
163
+ const isEmpty = computed(() => blocks.value.length === 0);
164
+
165
+ const selectedBlock = computed(() =>
166
+ blocks.value.find((b) => b.id === selectedBlockId.value) ?? null,
167
+ );
168
+
169
+ return {
170
+ blocks: readonly(blocks) as Readonly<Ref<EditorBlock[]>>,
171
+ selectedBlockId: readonly(selectedBlockId),
172
+ selectedBlock,
173
+ isEmpty,
174
+ addBlock,
175
+ removeBlock,
176
+ clearBlocks,
177
+ updateBlock,
178
+ moveBlock,
179
+ moveBlockUp,
180
+ moveBlockDown,
181
+ duplicateBlock,
182
+ replaceBlock,
183
+ selectBlock,
184
+ getBlockIndex,
185
+ toBlockTuples,
186
+ fromBlockTuples,
187
+ };
188
+ }
189
+
190
+ export type BlockEditor = ReturnType<typeof useBlockEditor>;
package/vue/index.ts ADDED
@@ -0,0 +1,59 @@
1
+ // @commonpub/editor/vue — Vue 3 block editor components and composables
2
+ //
3
+ // Usage:
4
+ // import { BlockCanvas, BlockPicker, useBlockEditor } from '@commonpub/editor/vue';
5
+ // import type { EditorBlock, BlockEditor } from '@commonpub/editor/vue';
6
+
7
+ // --- Composables ---
8
+ export { useBlockEditor } from './composables/useBlockEditor.js';
9
+ export type { BlockEditor } from './composables/useBlockEditor.js';
10
+
11
+ // --- Infrastructure components ---
12
+ export { default as BlockCanvas } from './components/BlockCanvas.vue';
13
+ export { default as BlockWrapper } from './components/BlockWrapper.vue';
14
+ export { default as BlockPicker } from './components/BlockPicker.vue';
15
+ export { default as BlockInsertZone } from './components/BlockInsertZone.vue';
16
+ export { default as EditorBlocks } from './components/EditorBlocks.vue';
17
+ export { default as EditorSection } from './components/EditorSection.vue';
18
+ export { default as EditorShell } from './components/EditorShell.vue';
19
+ export { default as EditorTagInput } from './components/EditorTagInput.vue';
20
+ export { default as EditorVisibility } from './components/EditorVisibility.vue';
21
+
22
+ // --- Block components ---
23
+ export { default as TextBlock } from './components/blocks/TextBlock.vue';
24
+ export { default as HeadingBlock } from './components/blocks/HeadingBlock.vue';
25
+ export { default as CodeBlock } from './components/blocks/CodeBlock.vue';
26
+ export { default as ImageBlock } from './components/blocks/ImageBlock.vue';
27
+ export { default as QuoteBlock } from './components/blocks/QuoteBlock.vue';
28
+ export { default as CalloutBlock } from './components/blocks/CalloutBlock.vue';
29
+ export { default as DividerBlock } from './components/blocks/DividerBlock.vue';
30
+ export { default as VideoBlock } from './components/blocks/VideoBlock.vue';
31
+ export { default as EmbedBlock } from './components/blocks/EmbedBlock.vue';
32
+ export { default as GalleryBlock } from './components/blocks/GalleryBlock.vue';
33
+ export { default as MarkdownBlock } from './components/blocks/MarkdownBlock.vue';
34
+ export { default as MathBlock } from './components/blocks/MathBlock.vue';
35
+ export { default as PartsListBlock } from './components/blocks/PartsListBlock.vue';
36
+ export { default as BuildStepBlock } from './components/blocks/BuildStepBlock.vue';
37
+ export { default as ToolListBlock } from './components/blocks/ToolListBlock.vue';
38
+ export { default as DownloadsBlock } from './components/blocks/DownloadsBlock.vue';
39
+ export { default as QuizBlock } from './components/blocks/QuizBlock.vue';
40
+ export { default as SliderBlock } from './components/blocks/SliderBlock.vue';
41
+ export { default as CheckpointBlock } from './components/blocks/CheckpointBlock.vue';
42
+ export { default as SectionHeaderBlock } from './components/blocks/SectionHeaderBlock.vue';
43
+
44
+ // --- Provide/inject keys ---
45
+ export { BLOCK_COMPONENTS_KEY, UPLOAD_HANDLER_KEY, SEARCH_PRODUCTS_KEY } from './provide.js';
46
+ export type { BlockComponentMap } from './provide.js';
47
+
48
+ // --- Types ---
49
+ export type {
50
+ EditorBlock,
51
+ BlockEditorOptions,
52
+ BlockTypeDef,
53
+ BlockTypeGroup,
54
+ BlockDef,
55
+ BlockGroup,
56
+ } from './types.js';
57
+
58
+ // --- Utilities ---
59
+ export { sanitizeBlockHtml } from './utils.js';
package/vue/provide.ts ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Provide/inject keys for block editor component customization.
3
+ *
4
+ * Consumers can override individual block components by providing a component map:
5
+ *
6
+ * ```ts
7
+ * import { provide } from 'vue';
8
+ * import { BLOCK_COMPONENTS_KEY, type BlockComponentMap } from '@commonpub/editor/vue';
9
+ * import CustomImageBlock from './CustomImageBlock.vue';
10
+ *
11
+ * provide(BLOCK_COMPONENTS_KEY, { image: CustomImageBlock });
12
+ * ```
13
+ *
14
+ * BlockCanvas merges provided overrides with its built-in component map,
15
+ * so you only need to provide the components you want to override.
16
+ */
17
+ import type { InjectionKey, Component } from 'vue';
18
+
19
+ export type BlockComponentMap = Record<string, Component>;
20
+
21
+ /** Injection key for block component overrides */
22
+ export const BLOCK_COMPONENTS_KEY: InjectionKey<BlockComponentMap> = Symbol('cpub-block-components');
23
+
24
+ /** Injection key for upload handler */
25
+ export const UPLOAD_HANDLER_KEY: InjectionKey<(file: File) => Promise<{ url: string; width?: number | null; height?: number | null }>> = Symbol('cpub-upload-handler');
26
+
27
+ /** Injection key for product search handler */
28
+ export const SEARCH_PRODUCTS_KEY: InjectionKey<(query: string) => Promise<Array<{ id: string; name: string; slug: string; description: string | null; category: string | null; imageUrl: string | null; purchaseUrl: string | null }>>> = Symbol('cpub-search-products');
package/vue/types.ts ADDED
@@ -0,0 +1,40 @@
1
+ export interface EditorBlock {
2
+ id: string;
3
+ type: string;
4
+ content: Record<string, unknown>;
5
+ }
6
+
7
+ export interface BlockEditorOptions {
8
+ /** Override default content values per block type */
9
+ blockDefaults?: Record<string, () => Record<string, unknown>>;
10
+ }
11
+
12
+ // BlockEditor type is derived from the composable's return type — see useBlockEditor.ts
13
+ // Re-exported from the composable module to ensure type compatibility
14
+
15
+ export interface BlockTypeDef {
16
+ type: string;
17
+ label: string;
18
+ icon: string;
19
+ description?: string;
20
+ attrs?: Record<string, unknown>;
21
+ }
22
+
23
+ export interface BlockTypeGroup {
24
+ name: string;
25
+ blocks: BlockTypeDef[];
26
+ }
27
+
28
+ export interface BlockDef {
29
+ type: string;
30
+ label: string;
31
+ icon: string;
32
+ description?: string;
33
+ attrs?: Record<string, unknown>;
34
+ }
35
+
36
+ export interface BlockGroup {
37
+ name: string;
38
+ variant?: string;
39
+ blocks: BlockDef[];
40
+ }
package/vue/utils.ts ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * HTML sanitization for v-html bindings in block editor components.
3
+ *
4
+ * Content is sanitized at the API/storage layer:
5
+ * - Local content: structured blocks via TipTap (no raw HTML injection)
6
+ * - Federated content: sanitized on ingest
7
+ *
8
+ * This provides the interface for components that use v-html.
9
+ */
10
+ export function sanitizeBlockHtml(html: string): string {
11
+ return html;
12
+ }