@commonpub/layer 0.7.1 → 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.
Files changed (43) hide show
  1. package/components/editors/ArticleEditor.vue +11 -12
  2. package/components/editors/BlogEditor.vue +17 -18
  3. package/components/editors/ExplainerEditor.vue +13 -14
  4. package/components/editors/ProjectEditor.vue +17 -18
  5. package/composables/useMarkdownImport.ts +1 -1
  6. package/package.json +8 -8
  7. package/pages/docs/[siteSlug]/edit.vue +4 -4
  8. package/pages/federated-hubs/[id]/posts/[postId].vue +91 -13
  9. package/pages/hubs/[slug]/index.vue +1 -1
  10. package/pages/hubs/[slug]/posts/[postId].vue +12 -0
  11. package/pages/u/[username]/[type]/[slug]/edit.vue +2 -1
  12. package/server/api/federated-hubs/[id]/posts/[postId]/replies.get.ts +13 -0
  13. package/server/api/federation/hub-post-reply.post.ts +16 -10
  14. package/components/editors/BlockCanvas.vue +0 -487
  15. package/components/editors/BlockInsertZone.vue +0 -84
  16. package/components/editors/BlockPicker.vue +0 -285
  17. package/components/editors/BlockWrapper.vue +0 -192
  18. package/components/editors/EditorBlocks.vue +0 -248
  19. package/components/editors/EditorSection.vue +0 -81
  20. package/components/editors/EditorShell.vue +0 -196
  21. package/components/editors/EditorTagInput.vue +0 -114
  22. package/components/editors/EditorVisibility.vue +0 -110
  23. package/components/editors/blocks/BuildStepBlock.vue +0 -102
  24. package/components/editors/blocks/CalloutBlock.vue +0 -122
  25. package/components/editors/blocks/CheckpointBlock.vue +0 -27
  26. package/components/editors/blocks/CodeBlock.vue +0 -177
  27. package/components/editors/blocks/DividerBlock.vue +0 -22
  28. package/components/editors/blocks/DownloadsBlock.vue +0 -41
  29. package/components/editors/blocks/EmbedBlock.vue +0 -20
  30. package/components/editors/blocks/GalleryBlock.vue +0 -236
  31. package/components/editors/blocks/HeadingBlock.vue +0 -96
  32. package/components/editors/blocks/ImageBlock.vue +0 -271
  33. package/components/editors/blocks/MarkdownBlock.vue +0 -258
  34. package/components/editors/blocks/MathBlock.vue +0 -37
  35. package/components/editors/blocks/PartsListBlock.vue +0 -358
  36. package/components/editors/blocks/QuizBlock.vue +0 -47
  37. package/components/editors/blocks/QuoteBlock.vue +0 -101
  38. package/components/editors/blocks/SectionHeaderBlock.vue +0 -130
  39. package/components/editors/blocks/SliderBlock.vue +0 -318
  40. package/components/editors/blocks/TextBlock.vue +0 -201
  41. package/components/editors/blocks/ToolListBlock.vue +0 -70
  42. package/components/editors/blocks/VideoBlock.vue +0 -22
  43. package/composables/useBlockEditor.ts +0 -187
@@ -127,6 +127,11 @@ function replyDisplayName(reply: { author?: { displayName?: string | null; usern
127
127
  return reply.remoteActorName || 'Someone';
128
128
  }
129
129
 
130
+ function extractDomain(uri: string | null | undefined): string {
131
+ if (!uri) return '';
132
+ try { return new URL(uri).hostname; } catch { return ''; }
133
+ }
134
+
130
135
  function formatDate(d: string | Date): string {
131
136
  return new Date(d).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' });
132
137
  }
@@ -176,11 +181,13 @@ useSeoMeta({
176
181
  <div class="cpub-post-author">
177
182
  <div class="cpub-post-avatar">
178
183
  <img v-if="post.author?.avatarUrl" :src="post.author.avatarUrl" :alt="post.author?.displayName || post.author?.username" class="cpub-post-avatar-img" />
184
+ <img v-else-if="post.remoteActorAvatarUrl" :src="post.remoteActorAvatarUrl" :alt="post.remoteActorName || 'Remote user'" class="cpub-post-avatar-img" />
179
185
  <span v-else>{{ (post.author?.displayName || post.author?.username || post.remoteActorName || 'U').charAt(0).toUpperCase() }}</span>
180
186
  </div>
181
187
  <NuxtLink v-if="post.author" :to="`/u/${post.author.username}`" class="cpub-post-author-name">{{ post.author.displayName || post.author.username }}</NuxtLink>
182
188
  <span v-else class="cpub-post-author-name cpub-reply-remote">
183
189
  <i class="fa-solid fa-globe" title="Federated post"></i> {{ post.remoteActorName || 'Someone' }}
190
+ <span v-if="extractDomain(post.remoteActorUri)" class="cpub-remote-domain">@{{ extractDomain(post.remoteActorUri) }}</span>
184
191
  </span>
185
192
  <span class="cpub-post-sep">&middot;</span>
186
193
  <time class="cpub-post-time">{{ formatDate(post.createdAt) }}</time>
@@ -237,11 +244,13 @@ useSeoMeta({
237
244
  <div class="cpub-reply-author">
238
245
  <div class="cpub-reply-avatar">
239
246
  <img v-if="reply.author?.avatarUrl" :src="reply.author.avatarUrl" :alt="reply.author?.displayName || reply.author?.username" class="cpub-reply-avatar-img" />
247
+ <img v-else-if="reply.remoteActorAvatarUrl" :src="reply.remoteActorAvatarUrl" :alt="reply.remoteActorName || 'Remote user'" class="cpub-reply-avatar-img" />
240
248
  <span v-else>{{ (replyDisplayName(reply)).charAt(0).toUpperCase() }}</span>
241
249
  </div>
242
250
  <NuxtLink v-if="reply.author" :to="`/u/${reply.author.username}`" class="cpub-reply-author-name">{{ reply.author.displayName || reply.author.username }}</NuxtLink>
243
251
  <span v-else class="cpub-reply-author-name cpub-reply-remote">
244
252
  <i class="fa-solid fa-globe" title="Federated reply"></i> {{ reply.remoteActorName || 'Someone' }}
253
+ <span v-if="extractDomain(reply.remoteActorUri)" class="cpub-remote-domain">@{{ extractDomain(reply.remoteActorUri) }}</span>
245
254
  </span>
246
255
  <span class="cpub-post-sep">&middot;</span>
247
256
  <time class="cpub-post-time">{{ formatDate(reply.createdAt) }}</time>
@@ -259,11 +268,13 @@ useSeoMeta({
259
268
  <div class="cpub-reply-author">
260
269
  <div class="cpub-reply-avatar">
261
270
  <img v-if="child.author?.avatarUrl" :src="child.author.avatarUrl" :alt="child.author?.displayName || child.author?.username" class="cpub-reply-avatar-img" />
271
+ <img v-else-if="child.remoteActorAvatarUrl" :src="child.remoteActorAvatarUrl" :alt="child.remoteActorName || 'Remote user'" class="cpub-reply-avatar-img" />
262
272
  <span v-else>{{ (replyDisplayName(child)).charAt(0).toUpperCase() }}</span>
263
273
  </div>
264
274
  <NuxtLink v-if="child.author" :to="`/u/${child.author.username}`" class="cpub-reply-author-name">{{ child.author.displayName || child.author.username }}</NuxtLink>
265
275
  <span v-else class="cpub-reply-author-name cpub-reply-remote">
266
276
  <i class="fa-solid fa-globe" title="Federated reply"></i> {{ child.remoteActorName || 'Someone' }}
277
+ <span v-if="extractDomain(child.remoteActorUri)" class="cpub-remote-domain">@{{ extractDomain(child.remoteActorUri) }}</span>
267
278
  </span>
268
279
  <span class="cpub-post-sep">&middot;</span>
269
280
  <time class="cpub-post-time">{{ formatDate(child.createdAt) }}</time>
@@ -419,6 +430,7 @@ useSeoMeta({
419
430
  .cpub-reply-author-name:hover { color: var(--accent); }
420
431
  .cpub-reply-remote { display: inline-flex; align-items: center; gap: 4px; }
421
432
  .cpub-reply-remote > i { font-size: 10px; color: var(--accent); }
433
+ .cpub-remote-domain { font-size: 10px; color: var(--text-faint); font-weight: 400; }
422
434
 
423
435
  .cpub-reply-content { font-size: 13px; line-height: 1.6; color: var(--text); }
424
436
 
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
2
  import type { Component } from 'vue';
3
3
  import type { BlockTuple } from '@commonpub/editor';
4
+ import { BlockCanvas, useBlockEditor } from '@commonpub/editor/vue';
4
5
  import { isExplainerDocument, createEmptyDocument } from '@commonpub/explainer';
5
6
  import type { ExplainerDocument } from '@commonpub/explainer';
6
7
  import { ExplainerSectionEditor } from '@commonpub/explainer/vue';
@@ -434,7 +435,7 @@ async function handleUrlImport(result: ImportedContent): Promise<void> {
434
435
  <!-- Write mode — fallback generic editor -->
435
436
  <div v-else-if="mode === 'write'" class="cpub-editor-shell">
436
437
  <div class="cpub-editor-canvas">
437
- <EditorsBlockCanvas :block-editor="blockEditor" :block-types="[]" />
438
+ <BlockCanvas :block-editor="blockEditor" :block-types="[]" />
438
439
  </div>
439
440
  </div>
440
441
 
@@ -0,0 +1,13 @@
1
+ import { listFederatedHubPostReplies } from '@commonpub/server';
2
+
3
+ export default defineEventHandler(async (event) => {
4
+ requireFeature('federation');
5
+ const postId = getRouterParam(event, 'postId')!;
6
+ const query = getQuery(event);
7
+ const db = useDB();
8
+
9
+ return listFederatedHubPostReplies(db, postId, {
10
+ limit: query.limit ? Number(query.limit) : undefined,
11
+ offset: query.offset ? Number(query.offset) : undefined,
12
+ });
13
+ });
@@ -1,17 +1,19 @@
1
- import { sendPostToRemoteHub, getFederatedHubPost, getFederatedHub } from '@commonpub/server';
1
+ import { sendPostToRemoteHub, getFederatedHubPost, getFederatedHub, createFederatedHubPostReply } from '@commonpub/server';
2
+ import type { FederatedHubPostReplyItem } from '@commonpub/server';
2
3
  import { z } from 'zod';
3
4
 
4
5
  const replySchema = z.object({
5
6
  federatedHubPostId: z.string().uuid(),
6
7
  content: z.string().min(1).max(10000),
8
+ parentId: z.string().uuid().optional(),
7
9
  });
8
10
 
9
- export default defineEventHandler(async (event): Promise<{ success: boolean }> => {
11
+ export default defineEventHandler(async (event): Promise<FederatedHubPostReplyItem> => {
10
12
  requireFeature('federation');
11
13
  const user = requireAuth(event);
12
14
  const db = useDB();
13
15
  const config = useConfig();
14
- const { federatedHubPostId, content } = await parseBody(event, replySchema);
16
+ const { federatedHubPostId, content, parentId } = await parseBody(event, replySchema);
15
17
 
16
18
  const post = await getFederatedHubPost(db, federatedHubPostId);
17
19
  if (!post) {
@@ -23,7 +25,15 @@ export default defineEventHandler(async (event): Promise<{ success: boolean }> =
23
25
  throw createError({ statusCode: 404, statusMessage: 'Hub not found' });
24
26
  }
25
27
 
26
- const success = await sendPostToRemoteHub(
28
+ // Store locally
29
+ const reply = await createFederatedHubPostReply(db, user.id, {
30
+ federatedHubPostId,
31
+ content,
32
+ parentId,
33
+ });
34
+
35
+ // Send via AP (fire-and-forget — don't block on remote delivery)
36
+ sendPostToRemoteHub(
27
37
  db,
28
38
  user.id,
29
39
  user.username,
@@ -32,11 +42,7 @@ export default defineEventHandler(async (event): Promise<{ success: boolean }> =
32
42
  config.instance.domain,
33
43
  'text',
34
44
  post.objectUri,
35
- );
36
-
37
- if (!success) {
38
- throw createError({ statusCode: 502, statusMessage: 'Could not reach remote hub' });
39
- }
45
+ ).catch(() => { /* best-effort federation delivery */ });
40
46
 
41
- return { success };
47
+ return reply;
42
48
  });
@@ -1,487 +0,0 @@
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 type { Component } from 'vue';
13
- import type { BlockEditor, EditorBlock } from '../../composables/useBlockEditor';
14
- import type { BlockTypeGroup } from './BlockPicker.vue';
15
-
16
- // Direct imports — Nuxt auto-imports are compile-time only and don't work with <component :is>
17
- import TextBlock from './blocks/TextBlock.vue';
18
- import HeadingBlock from './blocks/HeadingBlock.vue';
19
- import CodeBlock from './blocks/CodeBlock.vue';
20
- import ImageBlock from './blocks/ImageBlock.vue';
21
- import QuoteBlock from './blocks/QuoteBlock.vue';
22
- import CalloutBlock from './blocks/CalloutBlock.vue';
23
- import DividerBlock from './blocks/DividerBlock.vue';
24
- import VideoBlock from './blocks/VideoBlock.vue';
25
- import EmbedBlock from './blocks/EmbedBlock.vue';
26
- import GalleryBlock from './blocks/GalleryBlock.vue';
27
- import PartsListBlock from './blocks/PartsListBlock.vue';
28
- import BuildStepBlock from './blocks/BuildStepBlock.vue';
29
- import ToolListBlock from './blocks/ToolListBlock.vue';
30
- import DownloadsBlock from './blocks/DownloadsBlock.vue';
31
- import QuizBlock from './blocks/QuizBlock.vue';
32
- import SliderBlock from './blocks/SliderBlock.vue';
33
- import CheckpointBlock from './blocks/CheckpointBlock.vue';
34
- import MathBlock from './blocks/MathBlock.vue';
35
- import SectionHeaderBlock from './blocks/SectionHeaderBlock.vue';
36
- import MarkdownBlock from './blocks/MarkdownBlock.vue';
37
-
38
- const BLOCK_COMPONENTS: Record<string, Component> = {
39
- paragraph: TextBlock,
40
- text: TextBlock,
41
- heading: HeadingBlock,
42
- code: CodeBlock,
43
- code_block: CodeBlock,
44
- codeBlock: CodeBlock,
45
- image: ImageBlock,
46
- gallery: GalleryBlock,
47
- quote: QuoteBlock,
48
- blockquote: QuoteBlock,
49
- callout: CalloutBlock,
50
- divider: DividerBlock,
51
- horizontal_rule: DividerBlock,
52
- horizontalRule: DividerBlock,
53
- video: VideoBlock,
54
- embed: EmbedBlock,
55
- partsList: PartsListBlock,
56
- buildStep: BuildStepBlock,
57
- toolList: ToolListBlock,
58
- downloads: DownloadsBlock,
59
- quiz: QuizBlock,
60
- interactiveSlider: SliderBlock,
61
- slider: SliderBlock,
62
- checkpoint: CheckpointBlock,
63
- mathNotation: MathBlock,
64
- math: MathBlock,
65
- bulletList: TextBlock,
66
- orderedList: TextBlock,
67
- sectionHeader: SectionHeaderBlock,
68
- markdown: MarkdownBlock,
69
- };
70
-
71
- const props = defineProps<{
72
- blockEditor: BlockEditor;
73
- blockTypes: BlockTypeGroup[];
74
- }>();
75
-
76
- // --- Block picker state ---
77
- const pickerVisible = ref(false);
78
- const pickerInsertIndex = ref(0);
79
- /** When non-null, slash command is replacing this block instead of inserting */
80
- const slashCommandBlockId = ref<string | null>(null);
81
-
82
- function openPicker(atIndex: number): void {
83
- slashCommandBlockId.value = null;
84
- pickerInsertIndex.value = atIndex;
85
- pickerVisible.value = true;
86
- }
87
-
88
- function openSlashPicker(block: EditorBlock): void {
89
- const idx = props.blockEditor.getBlockIndex(block.id);
90
- if (idx === -1) return;
91
- slashCommandBlockId.value = block.id;
92
- pickerInsertIndex.value = idx;
93
- pickerVisible.value = true;
94
- }
95
-
96
- function closePicker(): void {
97
- pickerVisible.value = false;
98
- slashCommandBlockId.value = null;
99
- }
100
-
101
- function onPickerSelect(type: string, attrs?: Record<string, unknown>): void {
102
- if (slashCommandBlockId.value) {
103
- // Slash command: replace the text block with the chosen type
104
- props.blockEditor.replaceBlock(slashCommandBlockId.value, type, attrs);
105
- } else {
106
- // Normal insert
107
- props.blockEditor.addBlock(type, attrs, pickerInsertIndex.value);
108
- }
109
- closePicker();
110
- }
111
-
112
- // --- Floating toolbar state ---
113
- const floatingToolbar = ref<{
114
- visible: boolean;
115
- top: number;
116
- left: number;
117
- blockId: string;
118
- }>({ visible: false, top: 0, left: 0, blockId: '' });
119
-
120
- function onSelectionChange(block: EditorBlock, hasSelection: boolean, rect: DOMRect | null): void {
121
- if (hasSelection && rect) {
122
- const toolbarWidth = 180; // approximate toolbar width
123
- const toolbarHeight = 44;
124
- const rawTop = rect.top - toolbarHeight;
125
- const rawLeft = rect.left + rect.width / 2;
126
- floatingToolbar.value = {
127
- visible: true,
128
- top: Math.max(4, rawTop),
129
- left: Math.max(toolbarWidth / 2 + 4, Math.min(rawLeft, window.innerWidth - toolbarWidth / 2 - 4)),
130
- blockId: block.id,
131
- };
132
- } else {
133
- floatingToolbar.value = { visible: false, top: 0, left: 0, blockId: '' };
134
- }
135
- }
136
-
137
- // --- Floating toolbar commands ---
138
- const blockRefs = ref<Map<string, { getEditor?: () => unknown }>>(new Map());
139
-
140
- function setBlockRef(blockId: string, el: unknown): void {
141
- if (el && typeof el === 'object' && 'getEditor' in el) {
142
- blockRefs.value.set(blockId, el as { getEditor: () => unknown });
143
- }
144
- }
145
-
146
- function getActiveEditor(): unknown {
147
- const ref = blockRefs.value.get(floatingToolbar.value.blockId);
148
- return ref?.getEditor?.() ?? null;
149
- }
150
-
151
- /** TipTap editor chain interface for toolbar commands */
152
- interface TipTapChainable {
153
- focus: () => TipTapChainable;
154
- toggleMark: (mark: string) => TipTapChainable;
155
- unsetLink: () => TipTapChainable;
156
- extendMarkRange: (type: string) => TipTapChainable;
157
- setLink: (attrs: { href: string }) => TipTapChainable;
158
- run: () => void;
159
- }
160
-
161
- interface TipTapEditor {
162
- chain: () => TipTapChainable;
163
- isActive: (name: string) => boolean;
164
- }
165
-
166
- function toggleMark(mark: string): void {
167
- const editor = getActiveEditor() as TipTapEditor | null;
168
- if (!editor) return;
169
- editor.chain().focus().toggleMark(mark).run();
170
- }
171
-
172
- function toggleLink(): void {
173
- const editor = getActiveEditor() as TipTapEditor | null;
174
- if (!editor) return;
175
- if (editor.isActive('link')) {
176
- editor.chain().focus().unsetLink().run();
177
- return;
178
- }
179
- const url = window.prompt('Enter URL:');
180
- if (url) {
181
- editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
182
- }
183
- }
184
-
185
- // --- Empty state ---
186
- function addFirstBlock(): void {
187
- props.blockEditor.addBlock('paragraph');
188
- }
189
-
190
- // --- Block actions ---
191
- function onSelect(block: EditorBlock): void {
192
- props.blockEditor.selectBlock(block.id);
193
- }
194
-
195
- function onDelete(block: EditorBlock): void {
196
- props.blockEditor.removeBlock(block.id);
197
- }
198
-
199
- function onDuplicate(block: EditorBlock): void {
200
- props.blockEditor.duplicateBlock(block.id);
201
- }
202
-
203
- function onMoveUp(block: EditorBlock): void {
204
- props.blockEditor.moveBlockUp(block.id);
205
- }
206
-
207
- function onMoveDown(block: EditorBlock): void {
208
- props.blockEditor.moveBlockDown(block.id);
209
- }
210
-
211
- function onBlockUpdate(block: EditorBlock, content: Record<string, unknown>): void {
212
- props.blockEditor.updateBlock(block.id, content);
213
- }
214
-
215
- /** Enter at end of a text block → create new paragraph below */
216
- function onEnterAtEnd(block: EditorBlock): void {
217
- const idx = props.blockEditor.getBlockIndex(block.id);
218
- if (idx === -1) return;
219
- props.blockEditor.addBlock('paragraph', undefined, idx + 1);
220
- }
221
-
222
- /** Backspace in empty text block → delete it and focus previous */
223
- function onBackspaceEmpty(block: EditorBlock): void {
224
- const idx = props.blockEditor.getBlockIndex(block.id);
225
- if (idx === -1) return;
226
- // Don't delete the last block
227
- if (props.blockEditor.blocks.value.length <= 1) return;
228
- props.blockEditor.removeBlock(block.id);
229
- }
230
-
231
- // --- Drag and drop ---
232
- const draggedBlockId = ref<string | null>(null);
233
-
234
- function onDragStart(block: EditorBlock): void {
235
- draggedBlockId.value = block.id;
236
- }
237
-
238
- function onDragEnd(): void {
239
- draggedBlockId.value = null;
240
- }
241
-
242
- function onDrop(atIndex: number, event: DragEvent): void {
243
- event.preventDefault();
244
- if (!draggedBlockId.value) return;
245
-
246
- const fromIndex = props.blockEditor.getBlockIndex(draggedBlockId.value);
247
- if (fromIndex === -1) return;
248
-
249
- const toIndex = atIndex > fromIndex ? atIndex - 1 : atIndex;
250
- props.blockEditor.moveBlock(fromIndex, toIndex);
251
- draggedBlockId.value = null;
252
- }
253
-
254
- // --- Click outside to deselect ---
255
- function onCanvasClick(): void {
256
- props.blockEditor.selectBlock(null);
257
- floatingToolbar.value = { visible: false, top: 0, left: 0, blockId: '' };
258
- }
259
-
260
- // --- Resolve block component ---
261
- function getBlockComponent(type: string): Component {
262
- return BLOCK_COMPONENTS[type] ?? TextBlock;
263
- }
264
-
265
- /** Compute auto-numbered content for buildStep blocks */
266
- function getBlockContent(block: EditorBlock, index: number): Record<string, unknown> {
267
- if (block.type === 'buildStep') {
268
- // Count how many buildStep blocks precede this one
269
- let stepNum = 1;
270
- for (let i = 0; i < index; i++) {
271
- if (props.blockEditor.blocks.value[i].type === 'buildStep') stepNum++;
272
- }
273
- return { ...block.content, stepNumber: stepNum };
274
- }
275
- return block.content;
276
- }
277
-
278
- /** Check if a block type uses the TextBlock component (supports slash commands) */
279
- function isTextBlock(type: string): boolean {
280
- return type === 'paragraph' || type === 'bulletList' || type === 'orderedList';
281
- }
282
- </script>
283
-
284
- <template>
285
- <div class="cpub-block-canvas" @click.self="onCanvasClick">
286
- <!-- Page card wrapper — mimics document editing feel -->
287
- <div class="cpub-canvas-page">
288
-
289
- <!-- Empty state — click to create first paragraph -->
290
- <div v-if="blockEditor.isEmpty.value" class="cpub-canvas-empty" @click="addFirstBlock">
291
- <div class="cpub-canvas-empty-icon"><i class="fa-solid fa-pen-nib"></i></div>
292
- <p class="cpub-canvas-empty-title">Start writing</p>
293
- <p class="cpub-canvas-empty-desc">Click here to begin, or use the sidebar to add blocks</p>
294
- </div>
295
-
296
- <!-- Insert zone at top -->
297
- <EditorsBlockInsertZone @insert="openPicker(0)" />
298
- <!-- Picker at top position -->
299
- <div v-if="pickerVisible && !slashCommandBlockId && pickerInsertIndex === 0" class="cpub-canvas-picker-anchor">
300
- <EditorsBlockPicker :groups="blockTypes" :visible="true" @select="onPickerSelect" @close="closePicker" />
301
- </div>
302
-
303
- <!-- Block list -->
304
- <template v-for="(block, index) in blockEditor.blocks.value" :key="block.id">
305
- <EditorsBlockWrapper
306
- :block="block"
307
- :selected="blockEditor.selectedBlockId.value === block.id"
308
- @select="onSelect(block)"
309
- @delete="onDelete(block)"
310
- @duplicate="onDuplicate(block)"
311
- @move-up="onMoveUp(block)"
312
- @move-down="onMoveDown(block)"
313
- @drag-start="onDragStart(block)"
314
- @drag-end="onDragEnd"
315
- >
316
- <component
317
- :is="getBlockComponent(block.type)"
318
- :ref="(el: unknown) => isTextBlock(block.type) && setBlockRef(block.id, el)"
319
- :content="getBlockContent(block, index)"
320
- @update="(c: Record<string, unknown>) => onBlockUpdate(block, c)"
321
- @slash-command="openSlashPicker(block)"
322
- @selection-change="(has: boolean, rect: DOMRect | null) => onSelectionChange(block, has, rect)"
323
- @enter-at-end="onEnterAtEnd(block)"
324
- @backspace-empty="onBackspaceEmpty(block)"
325
- />
326
- </EditorsBlockWrapper>
327
-
328
- <!-- Picker: slash command replaces this block -->
329
- <div v-if="pickerVisible && slashCommandBlockId === block.id" class="cpub-canvas-picker-anchor">
330
- <EditorsBlockPicker :groups="blockTypes" :visible="true" @select="onPickerSelect" @close="closePicker" />
331
- </div>
332
-
333
- <!-- Insert zone after each block -->
334
- <EditorsBlockInsertZone
335
- @insert="openPicker(index + 1)"
336
- @drop="onDrop(index + 1, $event)"
337
- />
338
-
339
- <!-- Picker: insert zone triggered at this position -->
340
- <div v-if="pickerVisible && !slashCommandBlockId && pickerInsertIndex === index + 1" class="cpub-canvas-picker-anchor">
341
- <EditorsBlockPicker :groups="blockTypes" :visible="true" @select="onPickerSelect" @close="closePicker" />
342
- </div>
343
- </template>
344
-
345
- </div><!-- /.cpub-canvas-page -->
346
-
347
- <!-- Floating text toolbar -->
348
- <Teleport to="body">
349
- <div
350
- v-if="floatingToolbar.visible"
351
- class="cpub-floating-toolbar"
352
- :style="{ top: floatingToolbar.top + 'px', left: floatingToolbar.left + 'px' }"
353
- >
354
- <button class="cpub-ft-btn" title="Bold" @mousedown.prevent="toggleMark('bold')">
355
- <i class="fa-solid fa-bold"></i>
356
- </button>
357
- <button class="cpub-ft-btn" title="Italic" @mousedown.prevent="toggleMark('italic')">
358
- <i class="fa-solid fa-italic"></i>
359
- </button>
360
- <button class="cpub-ft-btn" title="Strikethrough" @mousedown.prevent="toggleMark('strike')">
361
- <i class="fa-solid fa-strikethrough"></i>
362
- </button>
363
- <button class="cpub-ft-btn" title="Inline code" @mousedown.prevent="toggleMark('code')">
364
- <i class="fa-solid fa-code"></i>
365
- </button>
366
- <div class="cpub-ft-divider" />
367
- <button class="cpub-ft-btn" title="Link" @mousedown.prevent="toggleLink">
368
- <i class="fa-solid fa-link"></i>
369
- </button>
370
- </div>
371
- </Teleport>
372
- </div>
373
- </template>
374
-
375
- <style scoped>
376
- .cpub-block-canvas {
377
- padding: 36px 0 52px;
378
- min-height: 300px;
379
- position: relative;
380
- display: flex;
381
- flex-direction: column;
382
- align-items: center;
383
- }
384
-
385
- .cpub-canvas-page {
386
- width: 100%;
387
- max-width: 680px;
388
- background: var(--surface);
389
- border: var(--border-width-default) solid var(--border);
390
- box-shadow: var(--shadow-md);
391
- padding: 44px 56px;
392
- position: relative;
393
- }
394
-
395
- @media (max-width: 768px) {
396
- .cpub-canvas-page {
397
- border: none;
398
- box-shadow: none;
399
- padding: 16px;
400
- }
401
- .cpub-block-canvas {
402
- padding: 8px 0 48px;
403
- }
404
- }
405
-
406
- .cpub-canvas-empty {
407
- text-align: center;
408
- padding: 48px 24px 32px;
409
- cursor: pointer;
410
- border: 2px dashed transparent;
411
- transition: border-color 0.15s, background 0.15s;
412
- }
413
-
414
- .cpub-canvas-empty:hover {
415
- border-color: var(--accent-border);
416
- background: var(--accent-bg);
417
- }
418
-
419
- .cpub-canvas-empty-icon {
420
- font-size: 32px;
421
- color: var(--text-faint);
422
- margin-bottom: 12px;
423
- }
424
-
425
- .cpub-canvas-empty-title {
426
- font-size: 16px;
427
- font-weight: 600;
428
- color: var(--text-dim);
429
- margin-bottom: 6px;
430
- }
431
-
432
- .cpub-canvas-empty-desc {
433
- font-size: 12px;
434
- color: var(--text-faint);
435
- }
436
-
437
- .cpub-canvas-picker-anchor {
438
- position: relative;
439
- display: flex;
440
- justify-content: center;
441
- }
442
- </style>
443
-
444
- <!-- Floating toolbar styles (global since it's teleported) -->
445
- <style>
446
- .cpub-floating-toolbar {
447
- --ft-surface: rgba(255, 255, 255, 0.15);
448
- position: fixed;
449
- z-index: 200;
450
- display: flex;
451
- align-items: center;
452
- gap: 0;
453
- background: var(--text, #1a1a1a);
454
- border: var(--border-width-default) solid var(--border, #1a1a1a);
455
- box-shadow: var(--shadow-md);
456
- padding: 3px;
457
- transform: translateX(-50%);
458
- pointer-events: auto;
459
- }
460
-
461
- .cpub-ft-btn {
462
- width: 30px;
463
- height: 28px;
464
- display: flex;
465
- align-items: center;
466
- justify-content: center;
467
- background: transparent;
468
- border: none;
469
- color: var(--surface3, #eaeae7);
470
- cursor: pointer;
471
- font-size: 11px;
472
- padding: 0;
473
- transition: background 0.08s, color 0.08s;
474
- }
475
-
476
- .cpub-ft-btn:hover {
477
- background: var(--ft-surface);
478
- color: var(--surface, #fff);
479
- }
480
-
481
- .cpub-ft-divider {
482
- width: 2px;
483
- height: 18px;
484
- background: var(--ft-surface);
485
- margin: 0 2px;
486
- }
487
- </style>