@commonpub/editor 0.6.1 → 0.7.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonpub/editor",
3
- "version": "0.6.1",
3
+ "version": "0.7.1",
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/schema": "0.9.2",
54
- "@commonpub/config": "0.8.0"
53
+ "@commonpub/config": "0.9.0",
54
+ "@commonpub/schema": "0.9.4"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vue": "^3.4.0",
@@ -79,8 +79,10 @@
79
79
  },
80
80
  "devDependencies": {
81
81
  "@types/mdast": "^4.0.4",
82
+ "@vitejs/plugin-vue": "^6.0.5",
82
83
  "jsdom": "^25.0.0",
83
84
  "typescript": "^5.7.0",
85
+ "vite": "^6.4.1",
84
86
  "vitest": "^3.0.0",
85
87
  "vue": "^3.5.0"
86
88
  },
@@ -90,6 +92,7 @@
90
92
  "test": "vitest run",
91
93
  "lint": "eslint src/",
92
94
  "typecheck": "tsc --noEmit",
95
+ "playground": "vite --config playground/vite.config.ts",
93
96
  "clean": "rm -rf dist"
94
97
  }
95
98
  }
@@ -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';
@@ -118,7 +118,16 @@ function closePicker(): void {
118
118
 
119
119
  function onPickerSelect(type: string, attrs?: Record<string, unknown>): void {
120
120
  if (slashCommandBlockId.value) {
121
- props.blockEditor.replaceBlock(slashCommandBlockId.value, type, attrs);
121
+ // Only replace if the block is empty — otherwise insert below to preserve content
122
+ const block = props.blockEditor.blocks.value.find((b) => b.id === slashCommandBlockId.value);
123
+ const html = (block?.content?.html as string) ?? '';
124
+ const isEmpty = !html.replace(/<[^>]*>/g, '').trim();
125
+ if (isEmpty) {
126
+ props.blockEditor.replaceBlock(slashCommandBlockId.value, type, attrs);
127
+ } else {
128
+ const idx = props.blockEditor.getBlockIndex(slashCommandBlockId.value);
129
+ props.blockEditor.addBlock(type, attrs, idx + 1);
130
+ }
122
131
  } else {
123
132
  props.blockEditor.addBlock(type, attrs, pickerInsertIndex.value);
124
133
  }
@@ -300,6 +309,30 @@ function needsUpload(type: string): boolean {
300
309
  function needsSearch(type: string): boolean {
301
310
  return type === 'partsList';
302
311
  }
312
+
313
+ // --- Undo/Redo keyboard shortcuts ---
314
+ function onKeydown(event: KeyboardEvent): void {
315
+ 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
+ const el = document.activeElement;
322
+ if (el?.closest('.ProseMirror')) return;
323
+ const tag = el?.tagName;
324
+ if (tag === 'TEXTAREA' || tag === 'INPUT' || tag === 'SELECT') return;
325
+
326
+ event.preventDefault();
327
+ if (event.shiftKey) {
328
+ props.blockEditor.redo();
329
+ } else {
330
+ props.blockEditor.undo();
331
+ }
332
+ }
333
+
334
+ onMounted(() => { document.addEventListener('keydown', onKeydown); });
335
+ onUnmounted(() => { document.removeEventListener('keydown', onKeydown); });
303
336
  </script>
304
337
 
305
338
  <template>
@@ -42,6 +42,67 @@ export function useBlockEditor(initialBlocks?: BlockTuple[], options?: BlockEdit
42
42
  const blocks = ref<EditorBlock[]>([]);
43
43
  const selectedBlockId = ref<string | null>(null);
44
44
 
45
+ // --- Undo/Redo History ---
46
+ const MAX_HISTORY = 50;
47
+ const history: Array<{ blocks: EditorBlock[]; selectedBlockId: string | null }> = [];
48
+ const historyIndex = ref(-1);
49
+ const isRestoring = ref(false);
50
+
51
+ function cloneBlocks(): EditorBlock[] {
52
+ return blocks.value.map((b) => ({
53
+ id: b.id,
54
+ type: b.type,
55
+ content: JSON.parse(JSON.stringify(b.content)),
56
+ }));
57
+ }
58
+
59
+ /** Save current state snapshot to history (call AFTER mutation) */
60
+ function pushHistory(): void {
61
+ if (isRestoring.value) return;
62
+ // Truncate any future states if we branched from an earlier point
63
+ if (historyIndex.value < history.length - 1) {
64
+ history.splice(historyIndex.value + 1);
65
+ }
66
+ history.push({ blocks: cloneBlocks(), selectedBlockId: selectedBlockId.value });
67
+ if (history.length > MAX_HISTORY) {
68
+ history.shift();
69
+ }
70
+ historyIndex.value = history.length - 1;
71
+ }
72
+
73
+ function undo(): boolean {
74
+ if (historyIndex.value <= 0) return false;
75
+ historyIndex.value--;
76
+ const snapshot = history[historyIndex.value]!;
77
+ isRestoring.value = true;
78
+ blocks.value.splice(0, blocks.value.length, ...snapshot.blocks.map((b) => ({
79
+ id: b.id,
80
+ type: b.type,
81
+ content: JSON.parse(JSON.stringify(b.content)),
82
+ })));
83
+ selectedBlockId.value = snapshot.selectedBlockId;
84
+ isRestoring.value = false;
85
+ return true;
86
+ }
87
+
88
+ function redo(): boolean {
89
+ if (historyIndex.value >= history.length - 1) return false;
90
+ historyIndex.value++;
91
+ const snapshot = history[historyIndex.value]!;
92
+ isRestoring.value = true;
93
+ blocks.value.splice(0, blocks.value.length, ...snapshot.blocks.map((b) => ({
94
+ id: b.id,
95
+ type: b.type,
96
+ content: JSON.parse(JSON.stringify(b.content)),
97
+ })));
98
+ selectedBlockId.value = snapshot.selectedBlockId;
99
+ isRestoring.value = false;
100
+ return true;
101
+ }
102
+
103
+ const canUndo = computed(() => historyIndex.value > 0);
104
+ const canRedo = computed(() => historyIndex.value < history.length - 1);
105
+
45
106
  // Merge custom defaults with built-in defaults
46
107
  const defaults = options?.blockDefaults
47
108
  ? { ...BLOCK_DEFAULTS, ...options.blockDefaults }
@@ -54,12 +115,19 @@ export function useBlockEditor(initialBlocks?: BlockTuple[], options?: BlockEdit
54
115
  type,
55
116
  content: { ...content },
56
117
  }));
118
+ // Reset history — loading new content is not an undoable operation
119
+ history.splice(0, history.length);
120
+ historyIndex.value = -1;
121
+ pushHistory();
57
122
  }
58
123
 
59
124
  if (initialBlocks && initialBlocks.length > 0) {
60
125
  fromBlockTuples(initialBlocks);
61
126
  }
62
127
 
128
+ // Capture initial state as first history entry
129
+ pushHistory();
130
+
63
131
  // --- Serialize back to BlockTuples ---
64
132
  function toBlockTuples(): BlockTuple[] {
65
133
  return blocks.value.map((b) => [b.type, { ...b.content }]);
@@ -82,6 +150,7 @@ export function useBlockEditor(initialBlocks?: BlockTuple[], options?: BlockEdit
82
150
  }
83
151
 
84
152
  selectedBlockId.value = block.id;
153
+ pushHistory();
85
154
  return block.id;
86
155
  }
87
156
 
@@ -98,6 +167,7 @@ export function useBlockEditor(initialBlocks?: BlockTuple[], options?: BlockEdit
98
167
 
99
168
  blocks.value.splice(idx, 1, newBlock);
100
169
  selectedBlockId.value = newBlock.id;
170
+ pushHistory();
101
171
  return newBlock.id;
102
172
  }
103
173
 
@@ -108,11 +178,13 @@ export function useBlockEditor(initialBlocks?: BlockTuple[], options?: BlockEdit
108
178
  if (selectedBlockId.value === id) {
109
179
  selectedBlockId.value = null;
110
180
  }
181
+ pushHistory();
111
182
  }
112
183
 
113
184
  function clearBlocks(): void {
114
185
  blocks.value.splice(0, blocks.value.length);
115
186
  selectedBlockId.value = null;
187
+ pushHistory();
116
188
  }
117
189
 
118
190
  function updateBlock(id: string, content: Record<string, unknown>): void {
@@ -127,6 +199,7 @@ export function useBlockEditor(initialBlocks?: BlockTuple[], options?: BlockEdit
127
199
  if (toIndex < 0 || toIndex >= blocks.value.length) return;
128
200
  const [moved] = blocks.value.splice(fromIndex, 1);
129
201
  blocks.value.splice(toIndex, 0, moved!);
202
+ pushHistory();
130
203
  }
131
204
 
132
205
  function moveBlockUp(id: string): void {
@@ -150,6 +223,7 @@ export function useBlockEditor(initialBlocks?: BlockTuple[], options?: BlockEdit
150
223
  };
151
224
  blocks.value.splice(idx + 1, 0, clone);
152
225
  selectedBlockId.value = clone.id;
226
+ pushHistory();
153
227
  }
154
228
 
155
229
  function selectBlock(id: string | null): void {
@@ -184,6 +258,10 @@ export function useBlockEditor(initialBlocks?: BlockTuple[], options?: BlockEdit
184
258
  getBlockIndex,
185
259
  toBlockTuples,
186
260
  fromBlockTuples,
261
+ undo,
262
+ redo,
263
+ canUndo,
264
+ canRedo,
187
265
  };
188
266
  }
189
267