Skip to content

Commit bd082d9

Browse files
committed
Make mouse button behavior more configurable
FEATURE: Mouse button clicks can now be bound in keymaps by using names like `"LeftClick"` or `"Ctrl-Alt-MiddleTripleClick"`. When bound to a function, that function will be passed the position of the click as second argument. FEATURE: The behavior of mouse selection and dragging can now be customized with the [`configureMouse`](http://codemirror.net/doc/manual.html#option_configureMouse) option.
1 parent 51bb848 commit bd082d9

8 files changed

Lines changed: 225 additions & 93 deletions

File tree

doc/manual.html

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,26 @@ <h2>Configuration</h2>
314314
by <a href="#option_keyMap"><code>keyMap</code></a>. Should be
315315
either null, or a valid <a href="#keymaps">key map</a> value.</dd>
316316

317+
<dt id="option_configureMouse"><code><strong>configureMouse</strong>: fn(cm: CodeMirror, repeat: "single" | "double" | "triple", event: Event) → Object</code></dt>
318+
<dd>Allows you to configure the behavior of mouse selection and
319+
dragging. The function is called when the left mouse button is
320+
pressed. The returned object may have the following properties:
321+
<dl>
322+
<dt><code><strong>unit</strong>: "char" | "word" | "line" | "rectangle" | fn(CodeMirror, Pos) → {from: Pos, to: Pos}</code></dt>
323+
<dd>The unit by which to select. May be one of the built-in
324+
units, or a function that takes a position and returns a
325+
range around that, for a custom unit.</dd>
326+
<dt><code><strong>extend</strong>: bool</code></dt>
327+
<dd>Whether to extend the existing selection range or start a new one.</dd>
328+
<dt><code><strong>addNew</strong>: bool</code></dt>
329+
<dd>When enabled, this adds a new range to the existing selection, rather than replacing it.</dd>
330+
<dt><code><strong>moveOnDrag</strong>: bool</code></dt>
331+
<dd>When the mouse even drags content around inside the
332+
editor, this controls whether it is copied (false) or moved
333+
(true).</dt>
334+
</dl>
335+
</dd>
336+
317337
<dt id="option_lineWrapping"><code><strong>lineWrapping</strong>: boolean</code></dt>
318338
<dd>Whether CodeMirror should scroll or wrap for long lines.
319339
Defaults to <code>false</code> (scroll).</dd>
@@ -764,9 +784,10 @@ <h2>Events</h2>
764784
<section id=keymaps>
765785
<h2>Key Maps</h2>
766786

767-
<p>Key maps are ways to associate keys with functionality. A key map
768-
is an object mapping strings that identify the keys to functions
769-
that implement their functionality.</p>
787+
<p>Key maps are ways to associate keys and mouse buttons with
788+
functionality. A key map is an object mapping strings that
789+
identify the buttons to functions that implement their
790+
functionality.</p>
770791

771792
<p>The CodeMirror distributions comes
772793
with <a href="../demo/emacs.html">Emacs</a>, <a href="../demo/vim.html">Vim</a>,
@@ -798,6 +819,13 @@ <h2>Key Maps</h2>
798819
or <code>'q'</code>. Due to limitations in the way browsers fire
799820
key events, these may not be prefixed with modifiers.</p>
800821

822+
<p>To bind mouse buttons, use the names `LeftClick`,
823+
`MiddleClick`, and `RightClick`. These can also be prefixed with
824+
modifiers, and in addition, the word `Double` or `Triple` can be
825+
put before `Click` (as in `LeftDoubleClick`) to bind a double- or
826+
triple-click. The function for such a binding is passed the
827+
position that was clicked as second argument.</p>
828+
801829
<p id="normalizeKeyMap">Multi-stroke key bindings can be specified
802830
by separating the key names by spaces in the property name, for
803831
example <code>Ctrl-X Ctrl-V</code>. When a map contains

src/edit/key_events.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,11 @@ function lookupKeyForEditor(cm, name, handle) {
4040
|| lookupKey(name, cm.options.keyMap, handle, cm)
4141
}
4242

43+
// Note that, despite the name, this function is also used to check
44+
// for bound mouse clicks.
45+
4346
let stopSeq = new Delayed
44-
function dispatchKey(cm, name, e, handle) {
47+
export function dispatchKey(cm, name, e, handle) {
4548
let seq = cm.state.keySeq
4649
if (seq) {
4750
if (isModifierKey(name)) return "handled"

src/edit/methods.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getLineStyles, getStateBefore, takeToken } from "../line/highlight"
77
import { indentLine } from "../input/indent"
88
import { triggerElectric } from "../input/input"
99
import { onKeyDown, onKeyPress, onKeyUp } from "./key_events"
10+
import { onMouseDown } from "./mouse_events"
1011
import { getKeyMap } from "../input/keymap"
1112
import { endOfLine, moveLogically, moveVisually } from "../input/movement"
1213
import { methodOp, operation, runInOp } from "../display/operations"
@@ -258,6 +259,7 @@ export default function(CodeMirror) {
258259
triggerOnKeyDown: methodOp(onKeyDown),
259260
triggerOnKeyPress: methodOp(onKeyPress),
260261
triggerOnKeyUp: onKeyUp,
262+
triggerOnMouseDown: methodOp(onMouseDown),
261263

262264
execCommand: function(cmd) {
263265
if (commands.hasOwnProperty(cmd))

src/edit/mouse_events.js

Lines changed: 127 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,43 @@ import { activeElt } from "../util/dom"
1212
import { e_button, e_defaultPrevented, e_preventDefault, e_target, hasHandler, off, on, signal, signalDOMEvent } from "../util/event"
1313
import { dragAndDrop } from "../util/feature_detection"
1414
import { bind, countColumn, findColumn, sel_mouse } from "../util/misc"
15+
import { addModifierNames } from "../input/keymap"
16+
import { Pass } from "../util/misc"
17+
18+
import { dispatchKey } from "./key_events"
19+
import { commands } from "./commands"
20+
21+
const DOUBLECLICK_DELAY = 400
22+
23+
class PastClick {
24+
constructor(time, pos, button) {
25+
this.time = time
26+
this.pos = pos
27+
this.button = button
28+
}
29+
30+
compare(time, pos, button) {
31+
return this.time + DOUBLECLICK_DELAY > time &&
32+
cmp(pos, this.pos) == 0 && button == this.button
33+
}
34+
}
35+
36+
let lastClick, lastDoubleClick
37+
function clickRepeat(pos, button) {
38+
let now = +new Date
39+
if (lastDoubleClick && lastDoubleClick.compare(now, pos, button)) {
40+
lastClick = lastDoubleClick = null
41+
return "triple"
42+
} else if (lastClick && lastClick.compare(now, pos, button)) {
43+
lastDoubleClick = new PastClick(now, pos, button)
44+
lastClick = null
45+
return "double"
46+
} else {
47+
lastClick = new PastClick(now, pos, button)
48+
lastDoubleClick = null
49+
return "single"
50+
}
51+
}
1552

1653
// A mouse down can be a single click, double click, triple click,
1754
// start of selection drag, start of text drag, new cursor
@@ -34,61 +71,79 @@ export function onMouseDown(e) {
3471
return
3572
}
3673
if (clickInGutter(cm, e)) return
37-
let start = posFromMouse(cm, e)
74+
let pos = posFromMouse(cm, e), button = e_button(e), repeat = pos ? clickRepeat(pos, button) : "single"
3875
window.focus()
3976

40-
switch (e_button(e)) {
41-
case 1:
42-
// #3261: make sure, that we're not starting a second selection
43-
if (cm.state.selectingText)
44-
cm.state.selectingText(e)
45-
else if (start)
46-
leftButtonDown(cm, e, start)
47-
else if (e_target(e) == display.scroller)
48-
e_preventDefault(e)
49-
break
50-
case 2:
51-
if (webkit) cm.state.lastMiddleDown = +new Date
52-
if (start) extendSelection(cm.doc, start)
77+
// #3261: make sure, that we're not starting a second selection
78+
if (button == 1 && cm.state.selectingText)
79+
cm.state.selectingText(e)
80+
81+
if (pos && handleMappedButton(cm, button, pos, repeat, e)) return
82+
83+
if (button == 1) {
84+
if (pos) leftButtonDown(cm, pos, repeat, e)
85+
else if (e_target(e) == display.scroller) e_preventDefault(e)
86+
} else if (button == 2) {
87+
if (pos) extendSelection(cm.doc, pos)
5388
setTimeout(() => display.input.focus(), 20)
54-
e_preventDefault(e)
55-
break
56-
case 3:
89+
} else if (button == 3) {
5790
if (captureRightClick) onContextMenu(cm, e)
5891
else delayBlurEvent(cm)
59-
break
6092
}
6193
}
6294

63-
let lastClick, lastDoubleClick
64-
function leftButtonDown(cm, e, start) {
95+
function handleMappedButton(cm, button, pos, repeat, event) {
96+
let name = "Click"
97+
if (repeat == "double") name = "Double" + name
98+
else if (repeat == "triple") name = "Triple" + name
99+
name = (button == 1 ? "Left" : button == 2 ? "Middle" : "Right") + name
100+
101+
return dispatchKey(cm, addModifierNames(name, event), event, bound => {
102+
if (typeof bound == "string") bound = commands[bound]
103+
if (!bound) return false
104+
let done = false
105+
try {
106+
if (cm.isReadOnly()) cm.state.suppressEdits = true
107+
done = bound(cm, pos) != Pass
108+
} finally {
109+
cm.state.suppressEdits = false
110+
}
111+
return done
112+
})
113+
}
114+
115+
function configureMouse(cm, repeat, event) {
116+
let option = cm.getOption("configureMouse")
117+
let value = option ? option(cm, repeat, event) : {}
118+
if (value.unit == null) {
119+
let rect = chromeOS ? event.shiftKey && event.metaKey : event.altKey
120+
value.unit = rect ? "rectangle" : repeat == "single" ? "char" : repeat == "double" ? "word" : "line"
121+
}
122+
if (value.extend == null || cm.doc.extend) value.extend = cm.doc.extend || event.shiftKey
123+
if (value.addNew == null) value.addNew = mac ? event.metaKey : event.ctrlKey
124+
if (value.moveOnDrag == null) value.moveOnDrag = !(mac ? event.altKey : event.ctrlKey)
125+
return value
126+
}
127+
128+
function leftButtonDown(cm, pos, repeat, event) {
65129
if (ie) setTimeout(bind(ensureFocus, cm), 0)
66130
else cm.curOp.focus = activeElt()
67131

68-
let now = +new Date, type
69-
if (lastDoubleClick && lastDoubleClick.time > now - 400 && cmp(lastDoubleClick.pos, start) == 0) {
70-
type = "triple"
71-
} else if (lastClick && lastClick.time > now - 400 && cmp(lastClick.pos, start) == 0) {
72-
type = "double"
73-
lastDoubleClick = {time: now, pos: start}
74-
} else {
75-
type = "single"
76-
lastClick = {time: now, pos: start}
77-
}
132+
let behavior = configureMouse(cm, repeat, event)
78133

79-
let sel = cm.doc.sel, modifier = mac ? e.metaKey : e.ctrlKey, contained
134+
let sel = cm.doc.sel, contained
80135
if (cm.options.dragDrop && dragAndDrop && !cm.isReadOnly() &&
81-
type == "single" && (contained = sel.contains(start)) > -1 &&
82-
(cmp((contained = sel.ranges[contained]).from(), start) < 0 || start.xRel > 0) &&
83-
(cmp(contained.to(), start) > 0 || start.xRel < 0))
84-
leftButtonStartDrag(cm, e, start, modifier)
136+
repeat == "single" && (contained = sel.contains(pos)) > -1 &&
137+
(cmp((contained = sel.ranges[contained]).from(), pos) < 0 || pos.xRel > 0) &&
138+
(cmp(contained.to(), pos) > 0 || pos.xRel < 0))
139+
leftButtonStartDrag(cm, event, pos, behavior)
85140
else
86-
leftButtonSelect(cm, e, start, type, modifier)
141+
leftButtonSelect(cm, event, pos, behavior)
87142
}
88143

89144
// Start a text drag. When it ends, see if any dragging actually
90145
// happen, and treat as a click if it didn't.
91-
function leftButtonStartDrag(cm, e, start, modifier) {
146+
function leftButtonStartDrag(cm, event, pos, behavior) {
92147
let display = cm.display, moved = false
93148
let dragEnd = operation(cm, e => {
94149
if (webkit) display.scroller.draggable = false
@@ -99,8 +154,8 @@ function leftButtonStartDrag(cm, e, start, modifier) {
99154
off(display.scroller, "drop", dragEnd)
100155
if (!moved) {
101156
e_preventDefault(e)
102-
if (!modifier)
103-
extendSelection(cm.doc, start)
157+
if (!behavior.addNew)
158+
extendSelection(cm.doc, pos, null, null, behavior.extend)
104159
// Work around unexplainable focus problem in IE9 (#2127) and Chrome (#3081)
105160
if (webkit || ie && ie_version == 9)
106161
setTimeout(() => {document.body.focus(); display.input.focus()}, 20)
@@ -109,13 +164,13 @@ function leftButtonStartDrag(cm, e, start, modifier) {
109164
}
110165
})
111166
let mouseMove = function(e2) {
112-
moved = moved || Math.abs(e.clientX - e2.clientX) + Math.abs(e.clientY - e2.clientY) >= 10
167+
moved = moved || Math.abs(event.clientX - e2.clientX) + Math.abs(event.clientY - e2.clientY) >= 10
113168
}
114169
let dragStart = () => moved = true
115170
// Let the drag handler handle this.
116171
if (webkit) display.scroller.draggable = true
117172
cm.state.draggingText = dragEnd
118-
dragEnd.copy = mac ? e.altKey : e.ctrlKey
173+
dragEnd.copy = !behavior.moveOnDrag
119174
// IE's approach to draggable
120175
if (display.scroller.dragDrop) display.scroller.dragDrop()
121176
on(document, "mouseup", dragEnd)
@@ -127,13 +182,21 @@ function leftButtonStartDrag(cm, e, start, modifier) {
127182
setTimeout(() => display.input.focus(), 20)
128183
}
129184

185+
function rangeForUnit(cm, pos, unit) {
186+
if (unit == "char") return new Range(pos, pos)
187+
if (unit == "word") return cm.findWordAt(pos)
188+
if (unit == "line") return new Range(Pos(pos.line, 0), clipPos(cm.doc, Pos(pos.line + 1, 0)))
189+
let result = unit(cm, pos)
190+
return new Range(result.from, result.to)
191+
}
192+
130193
// Normal selection, as opposed to text dragging.
131-
function leftButtonSelect(cm, e, start, type, addNew) {
194+
function leftButtonSelect(cm, event, start, behavior) {
132195
let display = cm.display, doc = cm.doc
133-
e_preventDefault(e)
196+
e_preventDefault(event)
134197

135198
let ourRange, ourIndex, startSel = doc.sel, ranges = startSel.ranges
136-
if (addNew && !e.shiftKey) {
199+
if (behavior.addNew && !behavior.extend) {
137200
ourIndex = doc.sel.contains(start)
138201
if (ourIndex > -1)
139202
ourRange = ranges[ourIndex]
@@ -144,36 +207,27 @@ function leftButtonSelect(cm, e, start, type, addNew) {
144207
ourIndex = doc.sel.primIndex
145208
}
146209

147-
if (chromeOS ? e.shiftKey && e.metaKey : e.altKey) {
148-
type = "rect"
149-
if (!addNew) ourRange = new Range(start, start)
150-
start = posFromMouse(cm, e, true, true)
210+
if (behavior.unit == "rectangle") {
211+
if (!behavior.addNew) ourRange = new Range(start, start)
212+
start = posFromMouse(cm, event, true, true)
151213
ourIndex = -1
152-
} else if (type == "double") {
153-
let word = cm.findWordAt(start)
154-
if (cm.display.shift || doc.extend)
155-
ourRange = extendRange(doc, ourRange, word.anchor, word.head)
156-
else
157-
ourRange = word
158-
} else if (type == "triple") {
159-
let line = new Range(Pos(start.line, 0), clipPos(doc, Pos(start.line + 1, 0)))
160-
if (cm.display.shift || doc.extend)
161-
ourRange = extendRange(doc, ourRange, line.anchor, line.head)
162-
else
163-
ourRange = line
164214
} else {
165-
ourRange = extendRange(doc, ourRange, start)
215+
let range = rangeForUnit(cm, start, behavior.unit)
216+
if (behavior.extend)
217+
ourRange = extendRange(ourRange, range.anchor, range.head, behavior.extend)
218+
else
219+
ourRange = range
166220
}
167221

168-
if (!addNew) {
222+
if (!behavior.addNew) {
169223
ourIndex = 0
170224
setSelection(doc, new Selection([ourRange], 0), sel_mouse)
171225
startSel = doc.sel
172226
} else if (ourIndex == -1) {
173227
ourIndex = ranges.length
174228
setSelection(doc, normalizeSelection(ranges.concat([ourRange]), ourIndex),
175229
{scroll: false, origin: "*mouse"})
176-
} else if (ranges.length > 1 && ranges[ourIndex].empty() && type == "single" && !e.shiftKey) {
230+
} else if (ranges.length > 1 && ranges[ourIndex].empty() && behavior.unit == "char" && !behavior.extend) {
177231
setSelection(doc, normalizeSelection(ranges.slice(0, ourIndex).concat(ranges.slice(ourIndex + 1)), 0),
178232
{scroll: false, origin: "*mouse"})
179233
startSel = doc.sel
@@ -186,7 +240,7 @@ function leftButtonSelect(cm, e, start, type, addNew) {
186240
if (cmp(lastPos, pos) == 0) return
187241
lastPos = pos
188242

189-
if (type == "rect") {
243+
if (behavior.unit == "rectangle") {
190244
let ranges = [], tabSize = cm.options.tabSize
191245
let startCol = countColumn(getLine(doc, start.line).text, start.ch, tabSize)
192246
let posCol = countColumn(getLine(doc, pos.line).text, pos.ch, tabSize)
@@ -205,20 +259,14 @@ function leftButtonSelect(cm, e, start, type, addNew) {
205259
cm.scrollIntoView(pos)
206260
} else {
207261
let oldRange = ourRange
208-
let anchor = oldRange.anchor, head = pos
209-
if (type != "single") {
210-
let range
211-
if (type == "double")
212-
range = cm.findWordAt(pos)
213-
else
214-
range = new Range(Pos(pos.line, 0), clipPos(doc, Pos(pos.line + 1, 0)))
215-
if (cmp(range.anchor, anchor) > 0) {
216-
head = range.head
217-
anchor = minPos(oldRange.from(), range.anchor)
218-
} else {
219-
head = range.anchor
220-
anchor = maxPos(oldRange.to(), range.head)
221-
}
262+
let range = rangeForUnit(cm, pos, behavior.unit)
263+
let anchor = oldRange.anchor, head
264+
if (cmp(range.anchor, anchor) > 0) {
265+
head = range.head
266+
anchor = minPos(oldRange.from(), range.anchor)
267+
} else {
268+
head = range.anchor
269+
anchor = maxPos(oldRange.to(), range.head)
222270
}
223271
let ranges = startSel.ranges.slice(0)
224272
ranges[ourIndex] = new Range(clipPos(doc, anchor), head)
@@ -235,7 +283,7 @@ function leftButtonSelect(cm, e, start, type, addNew) {
235283

236284
function extend(e) {
237285
let curCount = ++counter
238-
let cur = posFromMouse(cm, e, true, type == "rect")
286+
let cur = posFromMouse(cm, e, true, behavior.unit == "rectangle")
239287
if (!cur) return
240288
if (cmp(cur, lastPos) != 0) {
241289
cm.curOp.focus = activeElt()

src/edit/options.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ export function defineOptions(CodeMirror) {
9292
if (next.attach) next.attach(cm, prev || null)
9393
})
9494
option("extraKeys", null)
95+
option("configureMouse", null)
9596

9697
option("lineWrapping", false, wrappingChanged, true)
9798
option("gutters", [], cm => {

0 commit comments

Comments
 (0)