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/.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 6003a5830d..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,7 +98,15 @@ 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) { + return; + } if (event.target.nodeName !== 'INPUT' && event.target.nodeName !== 'TEXTAREA' && document.activeElement.contentEditable !== 'true') { const key = [ [ 'ALT', event.altKey ], @@ -121,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; 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" >