@commonpub/editor 0.7.0 → 0.7.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/editor",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
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",
@@ -50,8 +50,8 @@
50
50
  "remark-rehype": "^11.1.2",
51
51
  "unified": "^11.0.5",
52
52
  "zod": "^4.3.6",
53
- "@commonpub/config": "0.9.0",
54
- "@commonpub/schema": "0.9.3"
53
+ "@commonpub/schema": "0.9.4",
54
+ "@commonpub/config": "0.9.0"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vue": "^3.4.0",
@@ -9,7 +9,7 @@
9
9
  * - Drag-and-drop reordering via BlockWrapper
10
10
  * - Floating text toolbar on selection (delegated to FloatingToolbar)
11
11
  */
12
- import { ref, inject, type Component } from 'vue';
12
+ import { ref, inject, onMounted, onUnmounted, type Component } from 'vue';
13
13
  import type { EditorBlock, BlockTypeGroup } from '../types.js';
14
14
  import type { BlockEditor } from '../composables/useBlockEditor.js';
15
15
  import { BLOCK_COMPONENTS_KEY, UPLOAD_HANDLER_KEY, SEARCH_PRODUCTS_KEY } from '../provide.js';
@@ -140,7 +140,8 @@ const floatingToolbar = ref<{
140
140
  top: number;
141
141
  left: number;
142
142
  blockId: string;
143
- }>({ visible: false, top: 0, left: 0, blockId: '' });
143
+ activeMarks: { bold: boolean; italic: boolean; strike: boolean; code: boolean; link: boolean };
144
+ }>({ visible: false, top: 0, left: 0, blockId: '', activeMarks: { bold: false, italic: false, strike: false, code: false, link: false } });
144
145
 
145
146
  function onSelectionChange(block: EditorBlock, hasSelection: boolean, rect: DOMRect | null): void {
146
147
  if (hasSelection && rect) {
@@ -148,14 +149,24 @@ function onSelectionChange(block: EditorBlock, hasSelection: boolean, rect: DOMR
148
149
  const toolbarHeight = 44;
149
150
  const rawTop = rect.top - toolbarHeight;
150
151
  const rawLeft = rect.left + rect.width / 2;
152
+ // Query active marks from the TipTap editor
153
+ const editor = getActiveEditorForBlock(block.id);
154
+ const marks = {
155
+ bold: editor?.isActive('bold') ?? false,
156
+ italic: editor?.isActive('italic') ?? false,
157
+ strike: editor?.isActive('strike') ?? false,
158
+ code: editor?.isActive('code') ?? false,
159
+ link: editor?.isActive('link') ?? false,
160
+ };
151
161
  floatingToolbar.value = {
152
162
  visible: true,
153
163
  top: Math.max(4, rawTop),
154
164
  left: Math.max(toolbarWidth / 2 + 4, Math.min(rawLeft, window.innerWidth - toolbarWidth / 2 - 4)),
155
165
  blockId: block.id,
166
+ activeMarks: marks,
156
167
  };
157
168
  } else {
158
- floatingToolbar.value = { visible: false, top: 0, left: 0, blockId: '' };
169
+ floatingToolbar.value = { visible: false, top: 0, left: 0, blockId: '', activeMarks: { bold: false, italic: false, strike: false, code: false, link: false } };
159
170
  }
160
171
  }
161
172
 
@@ -168,6 +179,11 @@ function setBlockRef(blockId: string, el: unknown): void {
168
179
  }
169
180
  }
170
181
 
182
+ function getActiveEditorForBlock(blockId: string): TipTapEditor | null {
183
+ const ref = blockRefs.value.get(blockId);
184
+ return (ref?.getEditor?.() as TipTapEditor) ?? null;
185
+ }
186
+
171
187
  function getActiveEditor(): unknown {
172
188
  const ref = blockRefs.value.get(floatingToolbar.value.blockId);
173
189
  return ref?.getEditor?.() ?? null;
@@ -193,6 +209,9 @@ function toggleMark(mark: string): void {
193
209
  editor.chain().focus().toggleMark(mark).run();
194
210
  }
195
211
 
212
+ // --- Link URL inline prompt ---
213
+ const linkPrompt = ref<{ visible: boolean; url: string }>({ visible: false, url: '' });
214
+
196
215
  function toggleLink(): void {
197
216
  const editor = getActiveEditor() as TipTapEditor | null;
198
217
  if (!editor) return;
@@ -200,10 +219,23 @@ function toggleLink(): void {
200
219
  editor.chain().focus().unsetLink().run();
201
220
  return;
202
221
  }
203
- const url = window.prompt('Enter URL:');
204
- if (url) {
205
- editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
206
- }
222
+ // Open inline prompt instead of window.prompt
223
+ linkPrompt.value = { visible: true, url: '' };
224
+ }
225
+
226
+ function applyLink(): void {
227
+ const url = linkPrompt.value.url.trim();
228
+ linkPrompt.value = { visible: false, url: '' };
229
+ if (!url) return;
230
+ const editor = getActiveEditor() as TipTapEditor | null;
231
+ if (!editor) return;
232
+ editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
233
+ }
234
+
235
+ function cancelLink(): void {
236
+ linkPrompt.value = { visible: false, url: '' };
237
+ const editor = getActiveEditor() as TipTapEditor | null;
238
+ editor?.chain().focus().run();
207
239
  }
208
240
 
209
241
  // --- Empty state ---
@@ -275,7 +307,7 @@ function onDrop(atIndex: number, event: DragEvent): void {
275
307
  // --- Click outside to deselect ---
276
308
  function onCanvasClick(): void {
277
309
  props.blockEditor.selectBlock(null);
278
- floatingToolbar.value = { visible: false, top: 0, left: 0, blockId: '' };
310
+ floatingToolbar.value = { visible: false, top: 0, left: 0, blockId: '', activeMarks: { bold: false, italic: false, strike: false, code: false, link: false } };
279
311
  }
280
312
 
281
313
  // --- Resolve block component (injected overrides take priority) ---
@@ -309,6 +341,57 @@ function needsUpload(type: string): boolean {
309
341
  function needsSearch(type: string): boolean {
310
342
  return type === 'partsList';
311
343
  }
344
+
345
+ // --- Keyboard shortcuts ---
346
+ function onKeydown(event: KeyboardEvent): void {
347
+ const mod = event.metaKey || event.ctrlKey;
348
+ const el = document.activeElement;
349
+ const inProseMirror = !!el?.closest('.ProseMirror');
350
+ const inFormField = el?.tagName === 'TEXTAREA' || el?.tagName === 'INPUT' || el?.tagName === 'SELECT';
351
+
352
+ // Undo/Redo: Ctrl+Z / Ctrl+Shift+Z
353
+ if (mod && event.key.toLowerCase() === 'z') {
354
+ if (inProseMirror || inFormField) return;
355
+ event.preventDefault();
356
+ if (event.shiftKey) { props.blockEditor.redo(); } else { props.blockEditor.undo(); }
357
+ return;
358
+ }
359
+
360
+ // Block operations only when a block is selected and not editing text
361
+ const selectedId = props.blockEditor.selectedBlockId.value;
362
+ if (!selectedId || inProseMirror || inFormField) return;
363
+
364
+ // Ctrl+Shift+ArrowUp: move block up
365
+ if (mod && event.shiftKey && event.key === 'ArrowUp') {
366
+ event.preventDefault();
367
+ props.blockEditor.moveBlockUp(selectedId);
368
+ return;
369
+ }
370
+
371
+ // Ctrl+Shift+ArrowDown: move block down
372
+ if (mod && event.shiftKey && event.key === 'ArrowDown') {
373
+ event.preventDefault();
374
+ props.blockEditor.moveBlockDown(selectedId);
375
+ return;
376
+ }
377
+
378
+ // Ctrl+D: duplicate block
379
+ if (mod && event.key.toLowerCase() === 'd') {
380
+ event.preventDefault();
381
+ props.blockEditor.duplicateBlock(selectedId);
382
+ return;
383
+ }
384
+
385
+ // Delete / Backspace: remove selected block
386
+ if (event.key === 'Delete' || event.key === 'Backspace') {
387
+ event.preventDefault();
388
+ props.blockEditor.removeBlock(selectedId);
389
+ return;
390
+ }
391
+ }
392
+
393
+ onMounted(() => { document.addEventListener('keydown', onKeydown); });
394
+ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
312
395
  </script>
313
396
 
314
397
  <template>
@@ -385,23 +468,45 @@ function needsSearch(type: string): boolean {
385
468
  class="cpub-floating-toolbar"
386
469
  :style="{ top: floatingToolbar.top + 'px', left: floatingToolbar.left + 'px' }"
387
470
  >
388
- <button class="cpub-ft-btn" title="Bold" @mousedown.prevent="toggleMark('bold')">
471
+ <button class="cpub-ft-btn" :class="{ 'cpub-ft-btn--active': floatingToolbar.activeMarks.bold }" title="Bold" @mousedown.prevent="toggleMark('bold')">
389
472
  <i class="fa-solid fa-bold"></i>
390
473
  </button>
391
- <button class="cpub-ft-btn" title="Italic" @mousedown.prevent="toggleMark('italic')">
474
+ <button class="cpub-ft-btn" :class="{ 'cpub-ft-btn--active': floatingToolbar.activeMarks.italic }" title="Italic" @mousedown.prevent="toggleMark('italic')">
392
475
  <i class="fa-solid fa-italic"></i>
393
476
  </button>
394
- <button class="cpub-ft-btn" title="Strikethrough" @mousedown.prevent="toggleMark('strike')">
477
+ <button class="cpub-ft-btn" :class="{ 'cpub-ft-btn--active': floatingToolbar.activeMarks.strike }" title="Strikethrough" @mousedown.prevent="toggleMark('strike')">
395
478
  <i class="fa-solid fa-strikethrough"></i>
396
479
  </button>
397
- <button class="cpub-ft-btn" title="Inline code" @mousedown.prevent="toggleMark('code')">
480
+ <button class="cpub-ft-btn" :class="{ 'cpub-ft-btn--active': floatingToolbar.activeMarks.code }" title="Inline code" @mousedown.prevent="toggleMark('code')">
398
481
  <i class="fa-solid fa-code"></i>
399
482
  </button>
400
483
  <div class="cpub-ft-divider" />
401
- <button class="cpub-ft-btn" title="Link" @mousedown.prevent="toggleLink">
484
+ <button class="cpub-ft-btn" :class="{ 'cpub-ft-btn--active': floatingToolbar.activeMarks.link }" title="Link" @mousedown.prevent="toggleLink">
402
485
  <i class="fa-solid fa-link"></i>
403
486
  </button>
404
487
  </div>
488
+ <!-- Inline link URL prompt -->
489
+ <div
490
+ v-if="linkPrompt.visible && floatingToolbar.visible"
491
+ class="cpub-link-prompt"
492
+ :style="{ top: (floatingToolbar.top + 38) + 'px', left: floatingToolbar.left + 'px' }"
493
+ >
494
+ <input
495
+ v-model="linkPrompt.url"
496
+ class="cpub-link-prompt-input"
497
+ type="url"
498
+ placeholder="https://..."
499
+ autofocus
500
+ @keydown.enter.prevent="applyLink"
501
+ @keydown.escape.prevent="cancelLink"
502
+ />
503
+ <button class="cpub-link-prompt-btn" @mousedown.prevent="applyLink">
504
+ <i class="fa-solid fa-check"></i>
505
+ </button>
506
+ <button class="cpub-link-prompt-btn cpub-link-prompt-btn--cancel" @mousedown.prevent="cancelLink">
507
+ <i class="fa-solid fa-xmark"></i>
508
+ </button>
509
+ </div>
405
510
  </Teleport>
406
511
  </div>
407
512
  </template>
@@ -512,10 +617,59 @@ function needsSearch(type: string): boolean {
512
617
  color: var(--surface, #fff);
513
618
  }
514
619
 
620
+ .cpub-ft-btn--active {
621
+ background: var(--ft-surface);
622
+ color: var(--accent, #5b9cf6);
623
+ }
624
+
515
625
  .cpub-ft-divider {
516
626
  width: 2px;
517
627
  height: 18px;
518
628
  background: var(--ft-surface);
519
629
  margin: 0 2px;
520
630
  }
631
+
632
+ .cpub-link-prompt {
633
+ position: fixed;
634
+ z-index: 201;
635
+ display: flex;
636
+ align-items: center;
637
+ gap: 0;
638
+ background: var(--text, #1a1a1a);
639
+ border: var(--border-width-default, 2px) solid var(--border, #1a1a1a);
640
+ box-shadow: var(--shadow-md);
641
+ padding: 3px;
642
+ transform: translateX(-50%);
643
+ }
644
+
645
+ .cpub-link-prompt-input {
646
+ width: 200px;
647
+ padding: 4px 8px;
648
+ background: transparent;
649
+ border: none;
650
+ color: var(--surface3, #eaeae7);
651
+ font-family: var(--font-mono, monospace);
652
+ font-size: 11px;
653
+ outline: none;
654
+ }
655
+
656
+ .cpub-link-prompt-input::placeholder {
657
+ color: rgba(255, 255, 255, 0.3);
658
+ }
659
+
660
+ .cpub-link-prompt-btn {
661
+ width: 26px;
662
+ height: 26px;
663
+ display: flex;
664
+ align-items: center;
665
+ justify-content: center;
666
+ background: transparent;
667
+ border: none;
668
+ color: var(--surface3, #eaeae7);
669
+ cursor: pointer;
670
+ font-size: 10px;
671
+ }
672
+
673
+ .cpub-link-prompt-btn:hover { background: rgba(255, 255, 255, 0.15); color: #fff; }
674
+ .cpub-link-prompt-btn--cancel:hover { background: var(--red, #e04030); color: #fff; }
521
675
  </style>
@@ -47,6 +47,23 @@ function onDragLeave(): void {
47
47
  transition: height 0.15s;
48
48
  }
49
49
 
50
+ /* Always-visible thin line */
51
+ .cpub-insert-zone::before {
52
+ content: '';
53
+ position: absolute;
54
+ left: 0;
55
+ right: 0;
56
+ top: 50%;
57
+ height: 1px;
58
+ background: var(--border2, rgba(0, 0, 0, 0.06));
59
+ pointer-events: none;
60
+ }
61
+
62
+ .cpub-insert-zone:hover::before,
63
+ .cpub-insert-zone--dragover::before {
64
+ background: transparent;
65
+ }
66
+
50
67
  .cpub-insert-zone:hover,
51
68
  .cpub-insert-zone--dragover {
52
69
  height: 36px;
@@ -6,6 +6,7 @@
6
6
  * - Selected state (accent outline)
7
7
  * - Click-to-select
8
8
  */
9
+ import { ref, watch } from 'vue';
9
10
  import type { EditorBlock } from '../types.js';
10
11
 
11
12
  const props = defineProps<{
@@ -23,6 +24,29 @@ const emit = defineEmits<{
23
24
  'drag-end': [event: DragEvent];
24
25
  }>();
25
26
 
27
+ // Two-step delete: first click arms, second click confirms
28
+ const deleteArmed = ref(false);
29
+ let deleteTimer: ReturnType<typeof setTimeout> | null = null;
30
+
31
+ function handleDelete(): void {
32
+ if (deleteArmed.value) {
33
+ emit('delete');
34
+ deleteArmed.value = false;
35
+ if (deleteTimer) { clearTimeout(deleteTimer); deleteTimer = null; }
36
+ } else {
37
+ deleteArmed.value = true;
38
+ deleteTimer = setTimeout(() => { deleteArmed.value = false; }, 2000);
39
+ }
40
+ }
41
+
42
+ // Reset armed state when block loses selection
43
+ watch(() => props.selected, (sel) => {
44
+ if (!sel) {
45
+ deleteArmed.value = false;
46
+ if (deleteTimer) { clearTimeout(deleteTimer); deleteTimer = null; }
47
+ }
48
+ });
49
+
26
50
  function onDragStart(event: DragEvent): void {
27
51
  event.dataTransfer?.setData('text/plain', props.block.id);
28
52
  event.dataTransfer!.effectAllowed = 'move';
@@ -53,6 +77,9 @@ function onDragEnd(event: DragEvent): void {
53
77
  </button>
54
78
  </div>
55
79
 
80
+ <!-- Block type badge (top-left, shown on hover) -->
81
+ <div class="cpub-block-type-badge">{{ block.type.replace(/_/g, ' ') }}</div>
82
+
56
83
  <!-- Block controls (top-right, shown on hover) -->
57
84
  <div class="cpub-block-controls">
58
85
  <button class="cpub-block-ctrl" title="Move up" @click.stop="emit('move-up')">
@@ -64,8 +91,18 @@ function onDragEnd(event: DragEvent): void {
64
91
  <button class="cpub-block-ctrl" title="Duplicate" @click.stop="emit('duplicate')">
65
92
  <i class="fa-solid fa-copy"></i>
66
93
  </button>
67
- <button class="cpub-block-ctrl cpub-block-ctrl--danger" title="Delete" @click.stop="emit('delete')">
68
- <i class="fa-solid fa-trash"></i>
94
+ <button
95
+ class="cpub-block-ctrl"
96
+ :class="deleteArmed ? 'cpub-block-ctrl--armed' : 'cpub-block-ctrl--danger'"
97
+ :title="deleteArmed ? 'Click again to confirm' : 'Delete'"
98
+ @click.stop="handleDelete"
99
+ >
100
+ <template v-if="deleteArmed">
101
+ <i class="fa-solid fa-check"></i>
102
+ </template>
103
+ <template v-else>
104
+ <i class="fa-solid fa-trash"></i>
105
+ </template>
69
106
  </button>
70
107
  </div>
71
108
 
@@ -133,6 +170,25 @@ function onDragEnd(event: DragEvent): void {
133
170
  cursor: grabbing;
134
171
  }
135
172
 
173
+ .cpub-block-type-badge {
174
+ position: absolute;
175
+ top: -22px;
176
+ left: 0;
177
+ font-family: var(--font-mono);
178
+ font-size: 9px;
179
+ font-weight: 600;
180
+ text-transform: capitalize;
181
+ letter-spacing: 0.04em;
182
+ color: var(--text-faint);
183
+ opacity: 0;
184
+ transition: opacity 0.12s;
185
+ pointer-events: none;
186
+ }
187
+
188
+ .cpub-block-wrap:hover .cpub-block-type-badge {
189
+ opacity: 1;
190
+ }
191
+
136
192
  .cpub-block-controls {
137
193
  --ctrl-surface: rgba(255, 255, 255, 0.15);
138
194
  position: absolute;
@@ -177,6 +233,17 @@ function onDragEnd(event: DragEvent): void {
177
233
  color: var(--surface);
178
234
  }
179
235
 
236
+ .cpub-block-ctrl--armed {
237
+ background: var(--red);
238
+ color: var(--surface);
239
+ animation: cpub-pulse 0.6s ease-in-out infinite alternate;
240
+ }
241
+
242
+ @keyframes cpub-pulse {
243
+ from { opacity: 1; }
244
+ to { opacity: 0.6; }
245
+ }
246
+
180
247
  .cpub-block-inner {
181
248
  min-height: 20px;
182
249
  }
@@ -115,6 +115,10 @@ export function useBlockEditor(initialBlocks?: BlockTuple[], options?: BlockEdit
115
115
  type,
116
116
  content: { ...content },
117
117
  }));
118
+ // Reset history — loading new content is not an undoable operation
119
+ history.splice(0, history.length);
120
+ historyIndex.value = -1;
121
+ pushHistory();
118
122
  }
119
123
 
120
124
  if (initialBlocks && initialBlocks.length > 0) {