Skip to content
Open
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
16 changes: 8 additions & 8 deletions glue/crumble/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,15 +116,15 @@ button (now labelled "Hide Import/Export") again.
- `escape`: Unselect. Set current selection to the empty set.
- `delete`: Delete gates at current selection.
- `backspace`: Delete gates at current selection.
- `ctrl+delete`: Delete current circuit layer.
- `ctrl+delete` or `cmd+delete`: Delete current circuit layer.
- `ctrl+backspace`: Delete current circuit layer.
- `ctrl+insert`: Insert empty layer at current circuit layer, pushing current circuit layer ahead in time.
- `ctrl+z`: Undo
- `ctrl+y`: Redo
- `ctrl+shift+z`: Redo
- `ctrl+c`: Copy selection to clipboard (or entire layer if nothing selected).
- `ctrl+v`: Past clipboard contents at current selection (or entire layer if nothing selected).
- `ctrl+x`: Cut selection to clipboard (or entire layer if nothing selected).
- `ctrl+insert` or `cmd+enter`: Insert empty layer at current circuit layer, pushing current circuit layer ahead in time.
- `ctrl+z` or `cmd+z`: Undo
- `ctrl+y` or `cmd+y`: Redo
- `ctrl+shift+z` or `cmd+shift+z`: Redo
- `ctrl+c` or `cmd+c`: Copy selection to clipboard (or entire layer if nothing selected).
- `ctrl+v` or `cmd+v`: Paste clipboard contents at current selection (or entire layer if nothing selected).
- `ctrl+x` or `cmd+x`: Cut selection to clipboard (or entire layer if nothing selected).
- `f`: Reverse direction of selected two qubit gates (e.g. exchange the controls and targets of a CNOT).
- `g`: Reverse order of circuit layers, from the current layer to the next empty layer.
- `home`: Jump to the first layer of the circuit.
Expand Down
8 changes: 4 additions & 4 deletions glue/crumble/crumble.html
Original file line number Diff line number Diff line change
Expand Up @@ -118,8 +118,8 @@
<button id="btnClearSelectedMarkers">Clear Selected Marks (space)</button>
</div>
<div class="btn-group">
<button id="btnRedo">Redo (ctrl+Y)</button>
<button id="btnUndo">Undo (ctrl+Z)</button>
<button id="btnRedo">Redo (ctrl/cmd+Y)</button>
<button id="btnUndo">Undo (ctrl/cmd+Z)</button>
</div>
<div class="btn-group">
<button id="btnNextLayer">Next Layer (e)</button>
Expand All @@ -134,8 +134,8 @@
<button id="btnClearTimelineFocus">Clear Timeline Focus</button>
</div>
<div class="btn-group">
<button id="btnInsertLayer">Insert Layer (ctrl+insert)</button>
<button id="btnDeleteLayer">Delete Layer (ctrl+delete)</button>
<button id="btnInsertLayer">Insert Layer (ctrl+insert or cmd+enter)</button>
<button id="btnDeleteLayer">Delete Layer (ctrl/cmd+delete)</button>
</div>
</div>
<textarea id="txtDefaultCircuit" style="display: none">[[[DEFAULT_CIRCUIT_CONTENT_LITERAL]]]</textarea>
Expand Down
4 changes: 2 additions & 2 deletions glue/crumble/keyboard/toolbox.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ let DEF_ROW = [1, 2, 2, 2, 2, 0, 2, 2, 2, -1, -1, -1];
* @returns {undefined|!{row: !int, strength: !number}}
*/
function getFocusedRow(ev) {
if (ev.ctrlKey) {
if (ev.ctrlKey || ev.metaKey) {
return undefined;
}
let hasX = +ev.chord.has('x');
Expand All @@ -36,7 +36,7 @@ function getFocusedRow(ev) {
* @returns {undefined|!{col: !int, strength: !number}}
*/
function getFocusedCol(ev) {
if (ev.ctrlKey) {
if (ev.ctrlKey || ev.metaKey) {
return undefined;
}
let best = undefined;
Expand Down
122 changes: 108 additions & 14 deletions glue/crumble/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -197,7 +197,7 @@ editorState.canvas.addEventListener('mouseup', ev => {
editorState.mouseDownY = undefined;
editorState.curMouseX = ev.offsetX + OFFSET_X;
editorState.curMouseY = ev.offsetY + OFFSET_Y;
editorState.changeFocus(highlightedArea, ev.shiftKey, ev.ctrlKey);
editorState.changeFocus(highlightedArea, ev.shiftKey, ev.ctrlKey || ev.metaKey);
if (ev.buttons === 1) {
isInScrubber = false;
}
Expand All @@ -222,17 +222,8 @@ function makeChordHandlers() {
res.set('ctrl+shift+z', preview => { if (!preview) editorState.redo() });
res.set('ctrl+c', async preview => { await copyToClipboard(); });
res.set('ctrl+v', pasteFromClipboard);
res.set('ctrl+x', async preview => {
await copyToClipboard();
if (editorState.focusedSet.size === 0) {
let c = editorState.copyOfCurCircuit();
c.layers[editorState.curLayer].id_ops.clear();
c.layers[editorState.curLayer].markers.length = 0;
editorState.commit_or_preview(c, preview);
} else {
editorState.deleteAtFocus(preview);
}
});
res.set('ctrl+x', cutToClipboard);

res.set('l', preview => {
if (!preview) {
editorState.timelineSet = new Map(editorState.focusedSet.entries());
Expand Down Expand Up @@ -360,6 +351,8 @@ function makeChordHandlers() {
}

let fallbackEmulatedClipboard = undefined;
let pendingMetaPaste = false;
let pendingMetaPasteTimeout = undefined;
async function copyToClipboard() {
let c = editorState.copyOfCurCircuit();
c.layers = [c.layers[editorState.curLayer]]
Expand Down Expand Up @@ -397,6 +390,19 @@ async function pasteFromClipboard(preview) {
return;
}

pasteTextAtFocus(text, preview);
}

/**
* Applies already-read clipboard text at the current focus.
*
* Text can come from navigator.clipboard for Ctrl+V, or from a browser paste
* event for Cmd+V. Keeping this shared avoids duplicating paste behavior.
*
* @param {!string} text
* @param {!boolean} preview
*/
function pasteTextAtFocus(text, preview) {
let pastedCircuit = Circuit.fromStimCircuit(text);
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.

This is very similar to pasteFromClipboard. This code should be written once.

Copy link
Copy Markdown
Author

@GautamNambiar GautamNambiar May 18, 2026

Choose a reason for hiding this comment

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

Thanks! This is fixed now. Please also see my reply to the next comment for further clarification.

if (pastedCircuit.layers.length !== 1) {
throw new Error(text);
Expand Down Expand Up @@ -442,12 +448,86 @@ async function pasteFromClipboard(preview) {
editorState.commit_or_preview(newCircuit, preview);
}

function clearPendingMetaPaste() {
pendingMetaPaste = false;
if (pendingMetaPasteTimeout !== undefined) {
clearTimeout(pendingMetaPasteTimeout);
pendingMetaPasteTimeout = undefined;
}
}

async function cutToClipboard(preview) {
await copyToClipboard();
if (editorState.focusedSet.size === 0) {
let c = editorState.copyOfCurCircuit();
c.layers[editorState.curLayer].id_ops.clear();
c.layers[editorState.curLayer].markers.length = 0;
editorState.commit_or_preview(c, preview);
} else {
editorState.deleteAtFocus(preview);
}
}

const CHORD_HANDLERS = makeChordHandlers();
/**
* @param {!KeyboardEvent} ev
*/
function handleKeyboardEvent(ev) {
async function handleKeyboardEvent(ev) {
if (ev.type === 'keydown' && ev.metaKey) {
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.

Wouldn't these better be right next to the other chord handlers (makeChordHandlers) with meta+z etc.?

Copy link
Copy Markdown
Author

@GautamNambiar GautamNambiar May 18, 2026

Choose a reason for hiding this comment

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

I had originally tried what you suggested: meta+... chord handlers. But on my browsers (chrome and Safari, macOS), doing so, i.e. using the chord preview/finalization flow made shortcuts like cmd+Z and cmd+Enter unreliable. I don't know why, but handling these shortcuts immediately when the key is pressed made them consistent (this was suggested by chatGPT). It seems that for these shortcuts, bypassing the preview system seems to be harmless. Existing ctrl shortcuts still go through Crumble’s chord handler.

So I kept the cmd shortcut handling separate from makeChordHandlers.

cmd+V has one extra difference: it uses the browser paste event to get pasted text, instead of directly calling navigator.clipboard.readText(). This avoided the browser clipboard-read permission prompt I saw during testing.

ctrl+V still gets text using the existing navigator.clipboard.readText() path.

After the pasted text is obtained, cmd+V and ctrl+V now both call the same pasteTextAtFocus helper, so the actual Crumble editing behavior is shared (so the duplication is fixed).

A short comment has been added above the helper explaining why it exists.

I should note that I know very little about web development. I was asking chatGPT, testing, asking again and so on. So, probably there is a cleaner way to do this...

if (ev.repeat) {
ev.preventDefault();
editorState.chorder.handleFocusChanged();
return;
}

let key = ev.key.toLowerCase();

if (key === 'z' && !ev.shiftKey) {
ev.preventDefault();
editorState.chorder.handleFocusChanged();
editorState.undo();
return;
}
if ((key === 'z' && ev.shiftKey) || key === 'y') {
ev.preventDefault();
editorState.chorder.handleFocusChanged();
editorState.redo();
return;
}
if (key === 'c') {
ev.preventDefault();
editorState.chorder.handleFocusChanged();
await copyToClipboard();
return;
}
if (key === 'v') {
editorState.chorder.handleFocusChanged();
pendingMetaPaste = true;
pendingMetaPasteTimeout = setTimeout(clearPendingMetaPaste, 1000);
return;
}
if (key === 'x') {
ev.preventDefault();
editorState.chorder.handleFocusChanged();
await cutToClipboard(false);
return;
}
if (key === 'backspace' || key === 'delete') {
ev.preventDefault();
editorState.chorder.handleFocusChanged();
editorState.deleteCurLayer(false);
return;
}
if (key === 'enter') {
ev.preventDefault();
editorState.chorder.handleFocusChanged();
editorState.insertLayer(false);
return;
}
}

editorState.chorder.handleKeyEvent(ev);

if (ev.type === 'keydown') {
if (ev.key.toLowerCase() === 'q') {
let d = ev.shiftKey ? 5 : 1;
Expand Down Expand Up @@ -511,6 +591,20 @@ function handleKeyboardEvent(ev) {
}
}

document.addEventListener('paste', ev => {
if (!pendingMetaPaste) {
return;
}
clearPendingMetaPaste();

let text = ev.clipboardData.getData('text/plain');
if (text === '') {
return;
}

ev.preventDefault();
pasteTextAtFocus(text, false);
});
document.addEventListener('keydown', handleKeyboardEvent);
document.addEventListener('keyup', handleKeyboardEvent);

Expand All @@ -532,7 +626,7 @@ window.addEventListener('blur', () => {
for (let anchor of document.getElementById('examples-div').querySelectorAll('a')) {
anchor.onclick = ev => {
// Don't stop the user from e.g. opening the example in a new tab using ctrl+click.
if (ev.shiftKey || ev.ctrlKey || ev.altKey || ev.button !== 0) {
if (ev.shiftKey || ev.ctrlKey || ev.metaKey || ev.altKey || ev.button !== 0) {
return undefined;
}
let circuitText = anchor.href.split('#circuit=')[1];
Expand Down
Loading