Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/rich-text-insert-menu-backspace.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/widget-copy-paste-shortcut-conflicts.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 12 additions & 4 deletions packages/apostrophe/modules/@apostrophecms/area/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
Expand All @@ -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',
Expand All @@ -56,7 +63,8 @@ module.exports = {
type: 'command-menu-area-remove-widget'
},
shortcut: 'Backspace',
requireWidgetFocus: true
requireWidgetFocus: true,
skipOnTextSelection: true
}
},
modal: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
]);
});
Expand Down Expand Up @@ -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 ],
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
:editor="editor"
role="listbox"
tabindex="0"
@keydown="closeInsertMenu"
>
<div class="apos-rich-text-insert-menu-heading">
{{ $t('apostrophe:richTextInsertMenuHeading') }}
Expand All @@ -60,7 +61,6 @@
class="apos-rich-text-insert-menu-wrapper"
@keydown.prevent.arrow-up="focusInsertMenuItem(true)"
@keydown.prevent.arrow-down="focusInsertMenuItem()"
@keydown="closeInsertMenu"
>
<li
v-for="(item, index) in insert"
Expand Down Expand Up @@ -152,6 +152,7 @@ import merge from 'lodash/merge';
import { useAposStyles } from 'Modules/@apostrophecms/styles/composables/AposStyles.js';
import { useModalStore } from 'Modules/@apostrophecms/ui/stores/modal';
import { useWidgetStore } from 'Modules/@apostrophecms/ui/stores/widget';
import removeSlash from 'Modules/@apostrophecms/rich-text-widget/lib/remove-slash.js';

export default {
name: 'AposRichTextWidgetEditor',
Expand Down Expand Up @@ -837,6 +838,14 @@ export default {
) {
return;
}
if (e.key === 'Backspace') {
// Don't let the global remove-widget shortcut see this key
e.preventDefault();
e.stopPropagation();
removeSlash(this.editor);
this.editor.commands.focus();
return;
}
this.editor.commands.focus();
this.activeInsertMenuComponent = false;
// Only insert character keys
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@

<script setup>
import { ref, computed } from 'vue';
import removeEditorSlash from 'Modules/@apostrophecms/rich-text-widget/lib/remove-slash.js';

const props = defineProps({
name: {
type: String,
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious - why move this to a new file?

@myovchev myovchev Jul 2, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shared between multiple scripts. DRY.
You might not find it because the second import is named removeSlash.

}

function closeInsertMenuItem() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
}
Loading