Skip to content

Commit dc3cbb8

Browse files
committed
fix(editor): restore shift tap selection with native touch selection menu
1 parent d6ddb87 commit dc3cbb8

File tree

2 files changed

+76
-42
lines changed

2 files changed

+76
-42
lines changed

src/cm/touchSelectionMenu.js

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { EditorSelection } from "@codemirror/state";
12
import selectionMenu from "lib/selectionMenu";
23

34
const TAP_MAX_DELAY = 500;
@@ -126,9 +127,11 @@ class TouchSelectionMenuController {
126127
#view;
127128
#container;
128129
#getActiveFile;
130+
#isShiftSelectionActive;
129131
#stateSyncRaf = 0;
130132
#isScrolling = false;
131133
#isPointerInteracting = false;
134+
#shiftSelectionSession = null;
132135
#menuActive = false;
133136
#menuRequested = false;
134137
#enabled = true;
@@ -140,6 +143,8 @@ class TouchSelectionMenuController {
140143
this.#container =
141144
options.container || view.dom.closest(".editor-container") || view.dom;
142145
this.#getActiveFile = options.getActiveFile || (() => null);
146+
this.#isShiftSelectionActive =
147+
options.isShiftSelectionActive || (() => false);
143148
this.$menu = document.createElement("menu");
144149
this.$menu.className = "cursor-menu";
145150
this.#bindEvents();
@@ -170,12 +175,14 @@ class TouchSelectionMenuController {
170175
this.#clearMenuShowTimer();
171176
cancelAnimationFrame(this.#stateSyncRaf);
172177
this.#stateSyncRaf = 0;
178+
this.#shiftSelectionSession = null;
173179
this.#hideMenu(true);
174180
}
175181

176182
setEnabled(enabled) {
177183
this.#enabled = !!enabled;
178184
if (this.#enabled) return;
185+
this.#shiftSelectionSession = null;
179186
this.#menuRequested = false;
180187
this.#isPointerInteracting = false;
181188
this.#isScrolling = false;
@@ -246,6 +253,7 @@ class TouchSelectionMenuController {
246253

247254
onSessionChanged() {
248255
if (!this.#enabled) return;
256+
this.#shiftSelectionSession = null;
249257
this.#menuRequested = false;
250258
this.#isPointerInteracting = false;
251259
this.#isScrolling = false;
@@ -265,19 +273,29 @@ class TouchSelectionMenuController {
265273
#onGlobalPointerDown = (event) => {
266274
const target = event.target;
267275
if (this.$menu.contains(target)) return;
268-
if (this.#isIgnoredPointerTarget(target)) return;
276+
if (this.#isIgnoredPointerTarget(target)) {
277+
this.#shiftSelectionSession = null;
278+
return;
279+
}
269280
if (target instanceof Node && this.#view.dom.contains(target)) {
281+
this.#captureShiftSelection(event);
270282
this.#isPointerInteracting = true;
271283
this.#clearMenuShowTimer();
272284
this.#hideMenu();
273285
return;
274286
}
287+
this.#shiftSelectionSession = null;
275288
this.#isPointerInteracting = false;
276289
this.#menuRequested = false;
277290
this.#hideMenu();
278291
};
279292

280-
#onGlobalPointerUp = () => {
293+
#onGlobalPointerUp = (event) => {
294+
if (event.type === "pointerup") {
295+
this.#commitShiftSelection(event);
296+
} else {
297+
this.#shiftSelectionSession = null;
298+
}
281299
if (!this.#isPointerInteracting) return;
282300
this.#isPointerInteracting = false;
283301
if (!this.#enabled) return;
@@ -291,6 +309,56 @@ class TouchSelectionMenuController {
291309
this.#hideMenu();
292310
};
293311

312+
#captureShiftSelection(event) {
313+
if (!this.#canExtendSelection(event)) {
314+
this.#shiftSelectionSession = null;
315+
return;
316+
}
317+
318+
this.#shiftSelectionSession = {
319+
pointerId: event.pointerId,
320+
anchor: this.#view.state.selection.main.anchor,
321+
x: event.clientX,
322+
y: event.clientY,
323+
};
324+
}
325+
326+
#commitShiftSelection(event) {
327+
const session = this.#shiftSelectionSession;
328+
this.#shiftSelectionSession = null;
329+
if (!session) return;
330+
if (!this.#canExtendSelection(event)) return;
331+
if (event.pointerId !== session.pointerId) return;
332+
if (
333+
Math.hypot(event.clientX - session.x, event.clientY - session.y) >
334+
TAP_MAX_DISTANCE
335+
) {
336+
return;
337+
}
338+
const target = event.target;
339+
if (!(target instanceof Node) || !this.#view.dom.contains(target)) return;
340+
if (this.#isIgnoredPointerTarget(target)) return;
341+
342+
// Rely on pointer coordinates instead of click events so touch selection
343+
// keeps working when the browser/native path owns the actual tap.
344+
const head = this.#view.posAtCoords(
345+
{ x: event.clientX, y: event.clientY },
346+
false,
347+
);
348+
this.#view.dispatch({
349+
selection: EditorSelection.range(session.anchor, head),
350+
userEvent: "select.extend",
351+
});
352+
event.preventDefault();
353+
}
354+
355+
#canExtendSelection(event) {
356+
if (!this.#enabled) return false;
357+
if (!(event.isTrusted && event.isPrimary)) return false;
358+
if (typeof event.button === "number" && event.button !== 0) return false;
359+
return !!this.#isShiftSelectionActive(event);
360+
}
361+
294362
#shouldShowMenu() {
295363
if (this.#isScrolling || this.#isPointerInteracting || !this.#view.hasFocus)
296364
return false;

src/lib/editorManager.js

Lines changed: 6 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,7 @@
11
import sidebarApps from "sidebarApps";
22
import { indentUnit } from "@codemirror/language";
33
import { search } from "@codemirror/search";
4-
import {
5-
Compartment,
6-
EditorSelection,
7-
EditorState,
8-
Prec,
9-
StateEffect,
10-
} from "@codemirror/state";
4+
import { Compartment, EditorState, Prec, StateEffect } from "@codemirror/state";
115
import { oneDark } from "@codemirror/theme-one-dark";
126
import {
137
closeHoverTooltips,
@@ -200,37 +194,10 @@ async function EditorManager($header, $body) {
200194
},
201195
);
202196

203-
let shiftClickSelectionExtension;
204-
{
205-
const pointerIdMap = new Map();
206-
shiftClickSelectionExtension = EditorView.domEventHandlers({
207-
pointerup(event, view) {
208-
if (!appSettings.value.shiftClickSelection) return;
209-
if (!(event.isTrusted && event.isPrimary)) return;
210-
if (!event.shiftKey && quickTools.$footer.dataset.shift == null) return;
211-
const { pointerId } = event;
212-
const tid = setTimeout(() => pointerIdMap.delete(pointerId), 1001);
213-
pointerIdMap.set(pointerId, [view.state.selection.main.anchor, tid]);
214-
},
215-
click(event, view) {
216-
const { pointerId } = event;
217-
if (!pointerIdMap.has(pointerId)) return false;
218-
const [anchor, tid] = pointerIdMap.get(pointerId);
219-
clearTimeout(tid);
220-
pointerIdMap.delete(pointerId);
221-
view.dispatch({
222-
selection: EditorSelection.range(
223-
anchor,
224-
view.state.selection.main.anchor,
225-
),
226-
userEvent: "select.extend",
227-
});
228-
event.preventDefault();
229-
return true;
230-
},
231-
});
232-
}
233-
197+
const isShiftSelectionActive = (event) => {
198+
if (!appSettings.value.shiftClickSelection) return false;
199+
return !!event?.shiftKey || quickTools?.$footer?.dataset?.shift != null;
200+
};
234201
const touchSelectionUpdateExtension = EditorView.updateListener.of(
235202
(update) => {
236203
if (!touchSelectionController) return;
@@ -788,7 +755,6 @@ async function EditorManager($header, $body) {
788755
commandKeymapExtension: getCommandKeymapExtension(),
789756
themeExtension: themeCompartment.of(oneDark),
790757
pointerCursorVisibilityExtension,
791-
shiftClickSelectionExtension,
792758
touchSelectionUpdateExtension,
793759
searchExtension: search(),
794760
// Ensure read-only can be toggled later via compartment
@@ -844,6 +810,7 @@ async function EditorManager($header, $body) {
844810
touchSelectionController = createTouchSelectionMenu(editor, {
845811
container: $container,
846812
getActiveFile: () => manager?.activeFile || null,
813+
isShiftSelectionActive,
847814
});
848815

849816
// Provide minimal Ace-like API compatibility used by plugins
@@ -1150,7 +1117,6 @@ async function EditorManager($header, $body) {
11501117
// keep compartment in the state to allow dynamic theme changes later
11511118
themeExtension: themeCompartment.of(oneDark),
11521119
pointerCursorVisibilityExtension,
1153-
shiftClickSelectionExtension,
11541120
touchSelectionUpdateExtension,
11551121
searchExtension: search(),
11561122
// Keep dynamic compartments across state swaps

0 commit comments

Comments
 (0)