@commonpub/editor 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/editor",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
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",
@@ -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,25 @@ 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 } };
170
+ linkPrompt.value = { visible: false, url: '' };
159
171
  }
160
172
  }
161
173
 
@@ -168,6 +180,11 @@ function setBlockRef(blockId: string, el: unknown): void {
168
180
  }
169
181
  }
170
182
 
183
+ function getActiveEditorForBlock(blockId: string): TipTapEditor | null {
184
+ const ref = blockRefs.value.get(blockId);
185
+ return (ref?.getEditor?.() as TipTapEditor) ?? null;
186
+ }
187
+
171
188
  function getActiveEditor(): unknown {
172
189
  const ref = blockRefs.value.get(floatingToolbar.value.blockId);
173
190
  return ref?.getEditor?.() ?? null;
@@ -193,6 +210,9 @@ function toggleMark(mark: string): void {
193
210
  editor.chain().focus().toggleMark(mark).run();
194
211
  }
195
212
 
213
+ // --- Link URL inline prompt ---
214
+ const linkPrompt = ref<{ visible: boolean; url: string }>({ visible: false, url: '' });
215
+
196
216
  function toggleLink(): void {
197
217
  const editor = getActiveEditor() as TipTapEditor | null;
198
218
  if (!editor) return;
@@ -200,10 +220,23 @@ function toggleLink(): void {
200
220
  editor.chain().focus().unsetLink().run();
201
221
  return;
202
222
  }
203
- const url = window.prompt('Enter URL:');
204
- if (url) {
205
- editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
206
- }
223
+ // Open inline prompt instead of window.prompt
224
+ linkPrompt.value = { visible: true, url: '' };
225
+ }
226
+
227
+ function applyLink(): void {
228
+ const url = linkPrompt.value.url.trim();
229
+ linkPrompt.value = { visible: false, url: '' };
230
+ if (!url) return;
231
+ const editor = getActiveEditor() as TipTapEditor | null;
232
+ if (!editor) return;
233
+ editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
234
+ }
235
+
236
+ function cancelLink(): void {
237
+ linkPrompt.value = { visible: false, url: '' };
238
+ const editor = getActiveEditor() as TipTapEditor | null;
239
+ editor?.chain().focus().run();
207
240
  }
208
241
 
209
242
  // --- Empty state ---
@@ -275,7 +308,8 @@ function onDrop(atIndex: number, event: DragEvent): void {
275
308
  // --- Click outside to deselect ---
276
309
  function onCanvasClick(): void {
277
310
  props.blockEditor.selectBlock(null);
278
- floatingToolbar.value = { visible: false, top: 0, left: 0, blockId: '' };
311
+ floatingToolbar.value = { visible: false, top: 0, left: 0, blockId: '', activeMarks: { bold: false, italic: false, strike: false, code: false, link: false } };
312
+ linkPrompt.value = { visible: false, url: '' };
279
313
  }
280
314
 
281
315
  // --- Resolve block component (injected overrides take priority) ---
@@ -310,24 +344,51 @@ function needsSearch(type: string): boolean {
310
344
  return type === 'partsList';
311
345
  }
312
346
 
313
- // --- Undo/Redo keyboard shortcuts ---
347
+ // --- Keyboard shortcuts ---
314
348
  function onKeydown(event: KeyboardEvent): void {
315
349
  const mod = event.metaKey || event.ctrlKey;
316
- if (!mod || event.key.toLowerCase() !== 'z') return;
317
-
318
- // Don't intercept when an element with its own undo is focused:
319
- // - ProseMirror (TipTap text blocks have their own undo)
320
- // - textarea/input (native browser undo for code blocks, math, titles, etc.)
321
350
  const el = document.activeElement;
322
- if (el?.closest('.ProseMirror')) return;
323
- const tag = el?.tagName;
324
- if (tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT') return;
351
+ const inProseMirror = !!el?.closest('.ProseMirror');
352
+ const inFormField = el?.tagName === 'TEXTAREA' || el?.tagName === 'INPUT' || el?.tagName === 'SELECT';
353
+
354
+ // Undo/Redo: Ctrl+Z / Ctrl+Shift+Z
355
+ if (mod && event.key.toLowerCase() === 'z') {
356
+ if (inProseMirror || inFormField) return;
357
+ event.preventDefault();
358
+ if (event.shiftKey) { props.blockEditor.redo(); } else { props.blockEditor.undo(); }
359
+ return;
360
+ }
325
361
 
326
- event.preventDefault();
327
- if (event.shiftKey) {
328
- props.blockEditor.redo();
329
- } else {
330
- props.blockEditor.undo();
362
+ // Block operations only when a block is selected and not editing text
363
+ const selectedId = props.blockEditor.selectedBlockId.value;
364
+ if (!selectedId || inProseMirror || inFormField) return;
365
+
366
+ // Ctrl+Shift+ArrowUp: move block up
367
+ if (mod && event.shiftKey && event.key === 'ArrowUp') {
368
+ event.preventDefault();
369
+ props.blockEditor.moveBlockUp(selectedId);
370
+ return;
371
+ }
372
+
373
+ // Ctrl+Shift+ArrowDown: move block down
374
+ if (mod && event.shiftKey && event.key === 'ArrowDown') {
375
+ event.preventDefault();
376
+ props.blockEditor.moveBlockDown(selectedId);
377
+ return;
378
+ }
379
+
380
+ // Ctrl+D: duplicate block
381
+ if (mod && event.key.toLowerCase() === 'd') {
382
+ event.preventDefault();
383
+ props.blockEditor.duplicateBlock(selectedId);
384
+ return;
385
+ }
386
+
387
+ // Delete / Backspace: remove selected block
388
+ if (event.key === 'Delete' || event.key === 'Backspace') {
389
+ event.preventDefault();
390
+ props.blockEditor.removeBlock(selectedId);
391
+ return;
331
392
  }
332
393
  }
333
394
 
@@ -409,23 +470,45 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
409
470
  class="cpub-floating-toolbar"
410
471
  :style="{ top: floatingToolbar.top + 'px', left: floatingToolbar.left + 'px' }"
411
472
  >
412
- <button class="cpub-ft-btn" title="Bold" @mousedown.prevent="toggleMark('bold')">
473
+ <button class="cpub-ft-btn" :class="{ 'cpub-ft-btn--active': floatingToolbar.activeMarks.bold }" title="Bold" @mousedown.prevent="toggleMark('bold')">
413
474
  <i class="fa-solid fa-bold"></i>
414
475
  </button>
415
- <button class="cpub-ft-btn" title="Italic" @mousedown.prevent="toggleMark('italic')">
476
+ <button class="cpub-ft-btn" :class="{ 'cpub-ft-btn--active': floatingToolbar.activeMarks.italic }" title="Italic" @mousedown.prevent="toggleMark('italic')">
416
477
  <i class="fa-solid fa-italic"></i>
417
478
  </button>
418
- <button class="cpub-ft-btn" title="Strikethrough" @mousedown.prevent="toggleMark('strike')">
479
+ <button class="cpub-ft-btn" :class="{ 'cpub-ft-btn--active': floatingToolbar.activeMarks.strike }" title="Strikethrough" @mousedown.prevent="toggleMark('strike')">
419
480
  <i class="fa-solid fa-strikethrough"></i>
420
481
  </button>
421
- <button class="cpub-ft-btn" title="Inline code" @mousedown.prevent="toggleMark('code')">
482
+ <button class="cpub-ft-btn" :class="{ 'cpub-ft-btn--active': floatingToolbar.activeMarks.code }" title="Inline code" @mousedown.prevent="toggleMark('code')">
422
483
  <i class="fa-solid fa-code"></i>
423
484
  </button>
424
485
  <div class="cpub-ft-divider" />
425
- <button class="cpub-ft-btn" title="Link" @mousedown.prevent="toggleLink">
486
+ <button class="cpub-ft-btn" :class="{ 'cpub-ft-btn--active': floatingToolbar.activeMarks.link }" title="Link" @mousedown.prevent="toggleLink">
426
487
  <i class="fa-solid fa-link"></i>
427
488
  </button>
428
489
  </div>
490
+ <!-- Inline link URL prompt -->
491
+ <div
492
+ v-if="linkPrompt.visible && floatingToolbar.visible"
493
+ class="cpub-link-prompt"
494
+ :style="{ top: (floatingToolbar.top + 38) + 'px', left: floatingToolbar.left + 'px' }"
495
+ >
496
+ <input
497
+ v-model="linkPrompt.url"
498
+ class="cpub-link-prompt-input"
499
+ type="url"
500
+ placeholder="https://..."
501
+ autofocus
502
+ @keydown.enter.prevent="applyLink"
503
+ @keydown.escape.prevent="cancelLink"
504
+ />
505
+ <button class="cpub-link-prompt-btn" @mousedown.prevent="applyLink">
506
+ <i class="fa-solid fa-check"></i>
507
+ </button>
508
+ <button class="cpub-link-prompt-btn cpub-link-prompt-btn--cancel" @mousedown.prevent="cancelLink">
509
+ <i class="fa-solid fa-xmark"></i>
510
+ </button>
511
+ </div>
429
512
  </Teleport>
430
513
  </div>
431
514
  </template>
@@ -536,10 +619,59 @@ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
536
619
  color: var(--surface, #fff);
537
620
  }
538
621
 
622
+ .cpub-ft-btn--active {
623
+ background: var(--ft-surface);
624
+ color: var(--accent, #5b9cf6);
625
+ }
626
+
539
627
  .cpub-ft-divider {
540
628
  width: 2px;
541
629
  height: 18px;
542
630
  background: var(--ft-surface);
543
631
  margin: 0 2px;
544
632
  }
633
+
634
+ .cpub-link-prompt {
635
+ position: fixed;
636
+ z-index: 201;
637
+ display: flex;
638
+ align-items: center;
639
+ gap: 0;
640
+ background: var(--text, #1a1a1a);
641
+ border: var(--border-width-default, 2px) solid var(--border, #1a1a1a);
642
+ box-shadow: var(--shadow-md);
643
+ padding: 3px;
644
+ transform: translateX(-50%);
645
+ }
646
+
647
+ .cpub-link-prompt-input {
648
+ width: 200px;
649
+ padding: 4px 8px;
650
+ background: transparent;
651
+ border: none;
652
+ color: var(--surface3, #eaeae7);
653
+ font-family: var(--font-mono, monospace);
654
+ font-size: 11px;
655
+ outline: none;
656
+ }
657
+
658
+ .cpub-link-prompt-input::placeholder {
659
+ color: rgba(255, 255, 255, 0.3);
660
+ }
661
+
662
+ .cpub-link-prompt-btn {
663
+ width: 26px;
664
+ height: 26px;
665
+ display: flex;
666
+ align-items: center;
667
+ justify-content: center;
668
+ background: transparent;
669
+ border: none;
670
+ color: var(--surface3, #eaeae7);
671
+ cursor: pointer;
672
+ font-size: 10px;
673
+ }
674
+
675
+ .cpub-link-prompt-btn:hover { background: rgba(255, 255, 255, 0.15); color: #fff; }
676
+ .cpub-link-prompt-btn--cancel:hover { background: var(--red, #e04030); color: #fff; }
545
677
  </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
  }