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