From a6c322f61749067e9d853ea159ce4b06ceeca1d2 Mon Sep 17 00:00:00 2001 From: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:52:45 +0300 Subject: [PATCH 1/2] Fix backspace after slash deleting a rich-text widget --- .changeset/rich-text-insert-menu-backspace.md | 5 +++++ .../ui/apos/components/TheAposCommandMenu.vue | 4 ++++ .../components/AposRichTextWidgetEditor.vue | 11 ++++++++++- .../apos/components/AposTiptapInsertItem.vue | 17 +++-------------- .../ui/apos/lib/remove-slash.js | 18 ++++++++++++++++++ 5 files changed, 40 insertions(+), 15 deletions(-) create mode 100644 .changeset/rich-text-insert-menu-backspace.md create mode 100644 packages/apostrophe/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js diff --git a/.changeset/rich-text-insert-menu-backspace.md b/.changeset/rich-text-insert-menu-backspace.md new file mode 100644 index 0000000000..4993e8412f --- /dev/null +++ b/.changeset/rich-text-insert-menu-backspace.md @@ -0,0 +1,5 @@ +--- +"apostrophe": patch +--- + +Fixed pressing Backspace right after typing `/` in a rich text widget deleting the entire widget. Backspace now removes the slash and closes the insert menu. Global command menu shortcuts also no longer fire for key events already handled and prevented by other UI components. diff --git a/packages/apostrophe/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue b/packages/apostrophe/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue index 6003a5830d..2ebb4ffe22 100644 --- a/packages/apostrophe/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +++ b/packages/apostrophe/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue @@ -97,6 +97,10 @@ export default { : properties.itemName || 'default'; }, keyboardShortcutListener(event) { + // Keys already handled by more specific UI must not trigger shortcuts + if (event.defaultPrevented) { + return; + } if (event.target.nodeName !== 'INPUT' && event.target.nodeName !== 'TEXTAREA' && document.activeElement.contentEditable !== 'true') { const key = [ [ 'ALT', event.altKey ], diff --git a/packages/apostrophe/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue b/packages/apostrophe/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue index df58ce31c0..7dc500a506 100644 --- a/packages/apostrophe/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue +++ b/packages/apostrophe/modules/@apostrophecms/rich-text-widget/ui/apos/components/AposRichTextWidgetEditor.vue @@ -52,6 +52,7 @@ :editor="editor" role="listbox" tabindex="0" + @keydown="closeInsertMenu" >
{{ $t('apostrophe:richTextInsertMenuHeading') }} @@ -60,7 +61,6 @@ class="apos-rich-text-insert-menu-wrapper" @keydown.prevent.arrow-up="focusInsertMenuItem(true)" @keydown.prevent.arrow-down="focusInsertMenuItem()" - @keydown="closeInsertMenu" >
  • import { ref, computed } from 'vue'; +import removeEditorSlash from 'Modules/@apostrophecms/rich-text-widget/lib/remove-slash.js'; + const props = defineProps({ name: { type: String, @@ -87,20 +89,7 @@ function activate() { } function removeSlash() { - const state = props.editor.state; - const { $to } = state.selection; - if (state.selection.empty && $to?.nodeBefore?.text) { - const text = $to.nodeBefore.text; - if (text.slice(-1) === '/') { - const pos = props.editor.view.state.selection.$anchor.pos; - // Select the slash so an insert operation can replace it - props.editor.commands.setTextSelection({ - from: pos - 1, - to: pos - }); - props.editor.commands.deleteSelection(); - } - } + removeEditorSlash(props.editor); } function closeInsertMenuItem() { diff --git a/packages/apostrophe/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js b/packages/apostrophe/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js new file mode 100644 index 0000000000..df7a0b8893 --- /dev/null +++ b/packages/apostrophe/modules/@apostrophecms/rich-text-widget/ui/apos/lib/remove-slash.js @@ -0,0 +1,18 @@ +// Delete the trailing "/" that opened the rich text insert menu. + +export default function removeSlash(editor) { + const state = editor.state; + const { $to } = state.selection; + if (state.selection.empty && $to?.nodeBefore?.text) { + const text = $to.nodeBefore.text; + if (text.slice(-1) === '/') { + const pos = editor.view.state.selection.$anchor.pos; + // Select the slash so an insert operation can replace it + editor.commands.setTextSelection({ + from: pos - 1, + to: pos + }); + editor.commands.deleteSelection(); + } + } +} From 45964674c212cae57dfe3b2a17463c0697e68898 Mon Sep 17 00:00:00 2001 From: Miro Yovchev <2827783+myovchev@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:07:24 +0300 Subject: [PATCH 2/2] Fix copy/paste widget/text conflicts --- .../widget-copy-paste-shortcut-conflicts.md | 5 ++ .../modules/@apostrophecms/area/index.js | 16 +++- .../area/ui/apos/apps/AposAreas.js | 75 ++++++++++++++++++- .../ui/apos/components/TheAposCommandMenu.vue | 18 ++++- 4 files changed, 106 insertions(+), 8 deletions(-) create mode 100644 .changeset/widget-copy-paste-shortcut-conflicts.md diff --git a/.changeset/widget-copy-paste-shortcut-conflicts.md b/.changeset/widget-copy-paste-shortcut-conflicts.md new file mode 100644 index 0000000000..d9f7bed587 --- /dev/null +++ b/.changeset/widget-copy-paste-shortcut-conflicts.md @@ -0,0 +1,5 @@ +--- +"apostrophe": patch +--- + +Fixed the widget copy shortcut (Ctrl+C / Cmd+C) hijacking native text copy in edit mode. With an active text selection, the cut, copy and remove (Backspace) widget shortcuts now defer to the browser. Pasting a widget with Ctrl+V / Cmd+V now checks that the widget copy is still the most recent thing in the system clipboard, so text copied elsewhere in the meantime is no longer shadowed by a stale widget paste. The widget clipboard storage remains backward compatible with entries written by previous releases. diff --git a/packages/apostrophe/modules/@apostrophecms/area/index.js b/packages/apostrophe/modules/@apostrophecms/area/index.js index a1eccc3601..a4d9557365 100644 --- a/packages/apostrophe/modules/@apostrophecms/area/index.js +++ b/packages/apostrophe/modules/@apostrophecms/area/index.js @@ -20,7 +20,8 @@ module.exports = { type: 'command-menu-area-cut-widget' }, shortcut: 'Ctrl+X Meta+X', - requireWidgetFocus: true + requireWidgetFocus: true, + skipOnTextSelection: true }, [`${self.__meta.name}:copy-widget`]: { type: 'item', @@ -29,7 +30,8 @@ module.exports = { type: 'command-menu-area-copy-widget' }, shortcut: 'Ctrl+C Meta+C', - requireWidgetFocus: true + requireWidgetFocus: true, + skipOnTextSelection: true }, [`${self.__meta.name}:paste-widget`]: { type: 'item', @@ -38,7 +40,12 @@ module.exports = { type: 'command-menu-area-paste-widget' }, shortcut: 'Ctrl+V Meta+V', - requireWidgetFocus: true + requireWidgetFocus: true, + // Dispatched by the native paste event listener (see AposAreas.js), + // not by the command menu keydown listener, so the clipboard + // contents can arbitrate; keydown remains a fallback when the + // Clipboard API is unavailable + trigger: 'native' }, [`${self.__meta.name}:duplicate-widget`]: { type: 'item', @@ -56,7 +63,8 @@ module.exports = { type: 'command-menu-area-remove-widget' }, shortcut: 'Backspace', - requireWidgetFocus: true + requireWidgetFocus: true, + skipOnTextSelection: true } }, modal: { diff --git a/packages/apostrophe/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js b/packages/apostrophe/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js index f82b3e98ea..3b463e620f 100644 --- a/packages/apostrophe/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js +++ b/packages/apostrophe/modules/@apostrophecms/area/ui/apos/apps/AposAreas.js @@ -2,6 +2,7 @@ import createApp, { pinia } from 'Modules/@apostrophecms/ui/lib/vue'; import { useWidgetGraphStore } from 'Modules/@apostrophecms/ui/stores/widgetGraph.js'; import { nextTick } from 'vue'; +import { createId } from '@paralleldrive/cuid2'; export default function() { const mountedApps = new Map(); @@ -182,22 +183,90 @@ export default function() { } function createWidgetClipboardApp() { + const key = 'aposWidgetClipboard'; + const marker = 'apos-widget:'; // Simpler and more reliable to just talk to localStorage always and avoid the // storage event handle class Clipboard { set(widget) { - localStorage.setItem('aposWidgetClipboard', JSON.stringify(widget)); + const id = createId(); + localStorage.setItem(key, JSON.stringify({ + id, + widget + })); + // Stamp the OS clipboard so a later paste can tell whether this + // widget copy is still the most recent thing the user copied + navigator.clipboard?.writeText(`${marker}${id}`).catch(e => { + // eslint-disable-next-line no-console + console.warn('Unable to write the widget marker to the clipboard', e); + }); } get() { - const existing = window.localStorage.getItem('aposWidgetClipboard'); - return existing ? JSON.parse(existing) : null; + return this.getEntry()?.widget || null; + } + + getId() { + return this.getEntry()?.id || null; + } + + // Returns { id, widget } or null. Entries written by older releases + // hold a bare widget object and are returned with a null id. + getEntry() { + const existing = window.localStorage.getItem(key); + if (!existing) { + return null; + } + let parsed; + try { + parsed = JSON.parse(existing); + } catch (e) { + return null; + } + if (!parsed || typeof parsed !== 'object') { + return null; + } + if (typeof parsed.id === 'string' && parsed.widget?.type) { + return parsed; + } + return { + id: null, + widget: parsed + }; } } apos.area.widgetClipboard = new Clipboard(); + // Widget paste arrives through the native paste event rather than a + // Ctrl+V keydown interception, so the clipboard contents decide: a + // widget is pasted only when our marker is still the most recent copy + document.addEventListener('paste', e => { + if (isInsideEditable()) { + // User is typing, native paste wins + return; + } + const text = e.clipboardData?.getData('text/plain') || ''; + if (!text.startsWith(marker)) { + return; + } + // Never paste the marker itself as text at area level + e.preventDefault(); + if (text.slice(marker.length) === apos.area.widgetClipboard.getId()) { + apos.bus.$emit('command-menu-area-paste-widget'); + } + }); + + function isInsideEditable() { + const el = document.activeElement; + return !!el && ( + el.nodeName === 'INPUT' || + el.nodeName === 'TEXTAREA' || + el.isContentEditable || + !!el.closest?.('[contenteditable]') + ); + } } function cleanupOrphanedApps() { diff --git a/packages/apostrophe/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue b/packages/apostrophe/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue index 2ebb4ffe22..8dcd37abc0 100644 --- a/packages/apostrophe/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue +++ b/packages/apostrophe/modules/@apostrophecms/command-menu/ui/apos/components/TheAposCommandMenu.vue @@ -55,7 +55,9 @@ export default { shortcut.toUpperCase(), { ...command.action, - requireWidgetFocus: command.requireWidgetFocus || false + requireWidgetFocus: command.requireWidgetFocus || false, + skipOnTextSelection: command.skipOnTextSelection || false, + trigger: command.trigger || 'keydown' } ]); }); @@ -96,6 +98,10 @@ export default { ? this.getFirstNonShortcutModal(index + -1) : properties.itemName || 'default'; }, + hasTextSelection() { + const selection = window.getSelection(); + return !!selection && !selection.isCollapsed; + }, keyboardShortcutListener(event) { // Keys already handled by more specific UI must not trigger shortcuts if (event.defaultPrevented) { @@ -125,6 +131,16 @@ export default { if (action.requireWidgetFocus && !useWidgetStore().focusedWidget) { return; } + // A text selection wins over the shortcut, the browser + // performs its native action (e.g. copy the selected text) + if (action.skipOnTextSelection && this.hasTextSelection()) { + return; + } + // Handled by a native event listener (e.g. paste) when the + // Clipboard API is available; keydown is the legacy fallback + if (action.trigger === 'native' && navigator.clipboard) { + return; + } event.preventDefault(); apos.bus.$emit(action.type, action.payload); return;