Skip to content

Commit

Permalink
Multiselect anchors and multi-drag (#214)
Browse files Browse the repository at this point in the history
  • Loading branch information
edemaine committed Nov 13, 2022
1 parent a436fa2 commit 5d22d3e
Show file tree
Hide file tree
Showing 6 changed files with 156 additions and 44 deletions.
60 changes: 56 additions & 4 deletions client/Anchor.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,60 @@ export anchorMove = (obj, moved, index, coords) ->
# Math.abs(point.x - anchor.x) <= anchorVisualRadius and
# Math.abs(point.y - anchor.y) <= anchorVisualRadius

export anchorFromEvent = (e) ->
export anchorFromEvent = (e, anchorSelection) ->
## Find topmost anchor below mouse pointer,
## preferring a selected one if selection is provided.
## Returns {elt, id, index, selected} where `elt` is the anchor DOM object,
## or undefined if no anchor was found.
anchor = undefined
for elt in document.elementsFromPoint e.clientX, e.clientY
if elt.getAttribute('class') == 'anchor'
return elt
return
if elt.classList.contains 'anchor'
{id, index} = decodeAnchor elt
if not anchorSelection? or (selected = anchorSelection.has id, index)
return {elt, id, index, selected}
else
anchor ?= {elt, id, index, selected}
anchor if anchor?

export decodeAnchor = (elt) ->
id: elt.dataset.obj
index: parseInt elt.dataset.index, 10

export class AnchorSelection
constructor: (@board) ->
@selected = {}
has: (id, index) ->
{id, index} = id if id.id?
@selected[id]?[index]?
hasId: (id) ->
@selected[id]?
indicesForId: (id) ->
(parseInt index, 10 for index of @selected[id])
add: (id, index) ->
{id, index} = id if id.id?
@selected[id] ?= {}
@selected[id][index] = true
@board.render?.anchors?[id]?[index]?.classList.add 'select'
remove: (id, index) ->
{id, index} = id if id.id?
@board.render?.anchors?[id]?[index]?.classList.remove 'select'
return unless @selected[id]?
delete @selected[id][index]
## Check whether this id has any anchors still selected
any = false
for otherIndex of @selected[id] # eslint-disable-line coffee/no-unused-vars
any = true
break
delete @selected[id] unless any
toggle: (id, index) ->
if @has id, index
@remove id, index
else
@add id, index
ids: ->
id for id of @selected
clear: ->
for id, indices of @selected
for index of indices
@board.render?.anchors?[id]?[index]?.classList.remove 'select'
@selected = {}
6 changes: 6 additions & 0 deletions client/Board.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
## (Arguably, it should be merged with RenderObjects.)

import dom from './lib/dom'
import {AnchorSelection} from './Anchor'
import {Selection} from './Selection'
import {currentBoard} from './AppState'

Expand Down Expand Up @@ -34,6 +35,7 @@ export class Board
@svg.appendChild @root = dom.create 'g'
@transform = defaultTransform()
@selection = new Selection @
@anchorSelection = new AnchorSelection @
## Map from Object `_id` to a `Highlighter` instance
## that is currently highlighting that object.
@highlighters = {}
Expand Down Expand Up @@ -158,3 +160,7 @@ export class Board
child for child in @renderedChildren() when @selection.has child.dataset.id
renderedBBox: (children) ->
dom.unionSvgBBox @svg, children, @root

showAnchors: (show) ->
@render.showAnchors show
@anchorSelection.clear() # to ensure 'select' classes are up-to-date
9 changes: 6 additions & 3 deletions client/Selection.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
## Selection class is for maintaining and highlighted set of selected objects
## (which often come from Highlighter).

import {minSvgSize} from './BBox'
import {undoStack} from './UndoStack'
import {gridOffset} from './Grid'
import {selectColor, selectFill, selectFillOff} from './tools/color'
Expand Down Expand Up @@ -103,14 +104,16 @@ export class Highlighter
delete @board.highlighters[@id]
@target = @highlighted = @id = null
@selectorClear()
selectorStart: (start) ->
selectorStart: (@start) ->
scale = Math.min 1, @board.transform.scale
@board.root.appendChild @selector = dom.create 'rect',
class: 'selector'
x1: start.x
y1: start.y
x1: @start.x
y1: @start.y
'stroke-width': selectorWidth / scale
'stroke-dasharray': selectorDash / scale
selectorUpdate: (point) ->
dom.attr @selector, dom.pointsToRect @start, point, minSvgSize
selectorClear: ->
return unless @selector?
@selector.remove()
Expand Down
6 changes: 6 additions & 0 deletions client/main.styl
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,12 @@ svg
&:hover
stroke: rgba(0,0,0,0.888)
fill: rgba(0,0,0,0.5)
&.select
stroke: rgba(0,0,0,0.888)
fill: rgba(0,0,0,0.75)
&:hover
stroke: rgba(0,0,0,0.999)
fill: rgba(0,0,0,0.888)
line.cursor
opacity: 0.5
stroke: black
Expand Down
97 changes: 63 additions & 34 deletions client/tools/modes.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ defineTool
category: 'mode'
icon: 'mouse-pointer'
hotspot: [0.21875, 0.03515625]
help: <>Select objects by dragging rectangle or clicking on individual objects (toggling multiple if holding <kbd>Shift</kbd>). Then change their color/width, move by dragging (<kbd>Shift</kbd> for horizontal/vertical), copy via <kbd>{Ctrl}-C</kbd>, cut via <kbd>{Ctrl}-X</kbd>, paste via <kbd>{Ctrl}-V</kbd>, duplicate via <kbd>{Ctrl}-D</kbd>, or <kbd>Delete</kbd> them.</>
help: <>Select objects by dragging rectangle or clicking on individual objects (toggling multiple if holding <kbd>Shift</kbd>). Then change their color/width, move by dragging (<kbd>Shift</kbd> for horizontal/vertical) or using arrow keys, copy via <kbd>{Ctrl}-C</kbd>, cut via <kbd>{Ctrl}-X</kbd>, paste via <kbd>{Ctrl}-V</kbd>, duplicate via <kbd>{Ctrl}-D</kbd>, or <kbd>Delete</kbd> them.</>
hotkey: 's'
start: ->
pointers.objects = {}
Expand Down Expand Up @@ -193,8 +193,8 @@ defineTool
h = pointers[e.pointerId]
if h?.selector? # finished rectangular drag
board = currentBoard()
query = BBox.fromPoints [h.start, board.eventToPoint(e)]
{render, selection} = board
query = BBox.fromPoints [h.start, board.eventToPoint e]
# render is undefined when history mode starts but hasn't been advanced
for id of render?.dom ? {}
#for id from render.dbvt.query query
Expand Down Expand Up @@ -248,8 +248,7 @@ defineTool
h = pointers[e.pointerId]
if h.down
if h.selector?
here = currentBoard().eventToPoint e
dom.attr h.selector, dom.pointsToRect h.start, here, minSvgSize
h.selectorUpdate currentBoard().eventToPoint e
else if eventDistanceThreshold h.down, e, dragDist
h.down = true
here = currentBoard().eventToPoint e
Expand Down Expand Up @@ -282,38 +281,58 @@ defineTool
category: 'mode'
icon: 'anchor-select'
hotspot: [0.31, 0.16992]
help: "Drag anchor handles to reshape lines, rectangles, and ellipses."
help: <>Select anchor handles to reshape lines, rectangles, and ellipses. Drag anchor to move it; or drag rectangle or click on individual anchors (toggling multiple if holding <kbd>Shift</kbd>) and then drag to move (<kbd>Shift</kbd> for horizontal/vertical).</>
hotkey: 'a'
start: ->
currentBoard().render.showAnchors true
mainBoard.showAnchors true
pointers.objects = {}
stop: ->
currentBoard().render.showAnchors false
## currentBoard().showAnchors fails when switching to history mode
mainBoard.showAnchors false
delete pointers.objects
down: (e) ->
return if pointers[e.pointerId]?.down # in case of repeat events
if (anchor = anchorFromEvent e)?
id = anchor.dataset.obj
pointers.objects = {}
pointers.objects[id] = Objects.findOne id
pointers.objects[id].anchors = anchorsOf pointers.objects[id]
return unless pointers.objects[id]?
pointers.objects[id].anchorIndices = [parseInt anchor.dataset.index, 10]
pointers[e.pointerId] =
down: e
start: currentBoard().eventToPoint e
moved: null
edit: throttle.func (diffs) ->
Meteor.call 'objectsEdit', (diff for id, diff of diffs)
, ([older], [newer]) ->
[Object.assign older, newer]
## If we click on blank space, or shift/ctrl/meta-click within the
## selection rectangle, then we draw a selection rectangle.
#else
# h.selectorStart h.start
anchorSelection = currentBoard().anchorSelection
pointers[e.pointerId] ?= new Highlighter currentBoard()
h = pointers[e.pointerId]
return if h.down # in case of repeat events
h.down = e
h.start = currentBoard().eventToPoint e
h.moved = null
h.edit = throttle.func (diffs) ->
Meteor.call 'objectsEdit', (diff for id, diff of diffs)
, ([older], [newer]) ->
[Object.assign older, newer]
## Check for clicking on a selected anchor, to ensure dragging selection
## works even when another anchor is more topmost.
anchor = anchorFromEvent e, anchorSelection
## Deselect existing selection unless requesting multiselect
toggle = e.shiftKey or e.ctrlKey or e.metaKey
unless toggle or anchor?.selected
anchorSelection.clear()
## If we clicked on an anchor, then we update the selection
## and prepare for dragging it
if anchor?
unless anchorSelection.has anchor
anchorSelection.add anchor
else if toggle
anchorSelection.remove anchor
## Prevent dragging after deselecting an object
h.start = null
## If we click on blank space, then we draw a selection rectangle.
else
h.selectorStart h.start
## Refresh selected objects, in particular so pts and anchors up-to-date
pointers.objects = {}
for id in anchorSelection.ids()
pointers.objects[id] = obj = Objects.findOne id
obj.anchors = anchorsOf obj
move: (e) ->
#pointers[e.pointerId] ?= new AnchorHighlighter currentBoard()
pointers[e.pointerId] ?= new Highlighter currentBoard()
h = pointers[e.pointerId]
if h?.down
if eventDistanceThreshold h.down, e, dragDist
if h.down
if h.selector?
h.selectorUpdate currentBoard().eventToPoint e
else if eventDistanceThreshold h.down, e, dragDist
h.down = true
here = currentBoard().eventToPoint e
here = orthogonalPoint here, e, h.start
Expand All @@ -322,13 +341,14 @@ defineTool
y: here.y - h.start.y
motion = maybeSnapPointToGrid motion
## Don't set h.moved out here in case no objects selected
anchorSelection = currentBoard().anchorSelection
diffs = {}
for id, obj of pointers.objects when obj?
continue unless obj.anchorIndices.length
continue unless anchorSelection.hasId id
h.moved ?= {}
h.moved[id] ?= obj.pts[..]
moved = false
for index in obj.anchorIndices
for index in anchorSelection.indicesForId id
x = obj.anchors[index].x + motion.x
y = obj.anchors[index].y + motion.y
moved or= anchorMove obj, h.moved[id], index, {x, y}
Expand All @@ -337,8 +357,17 @@ defineTool
h.edit diffs if (id for id of diffs).length
up: (e) ->
h = pointers[e.pointerId]
#if h?.selector? # finished rectangular drag
if h?.moved # finished dragging objects
if h?.selector? # finished rectangular drag
board = currentBoard()
{render, anchorSelection} = board
query = BBox.fromPoints [h.start, board.eventToPoint e]
h.selectorClear()
for id of render.anchors ? {}
continue unless render.bbox[id]?.intersects query
for anchor, index in anchorsOf Objects.findOne id
if query.containsPoint anchor
anchorSelection.toggle id, index
else if h?.moved # finished dragging objects
h.edit.flush()
undoStack.push
type: 'multi'
Expand Down
22 changes: 19 additions & 3 deletions doc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,11 +259,27 @@ that define a
or
[<img src="icons/ellipse.svg" width="18" alt="Ellipse Icon"> Ellipse](#-ellipse-tool).

Switching to this mode displays small squares at all the draggable points.
Drag any of these points to the desired location.
Holding <kbd>Shift</kbd> while dragging constraints the move to be
Switching to this mode displays small squares at all the draggable points,
called "anchors".
Drag any of these anchors to the desired location.
Hold <kbd>Shift</kbd> while dragging to constrain the move to be
purely horizontal or vertical.

You can select multiple anchors in two ways:

1. Clicking/tapping on individual anchors while holding <kbd>Shift</kbd>,
to toggle selection.
This method is good for selecting a few specific anchors at different
locations, but it gets tedious if you want to manipulate several anchors
at once or several angles at the same location.
2. Dragging a rectangle around anchors to select the anchors
whose centers are within the rectangle.
Without <kbd>Shift</kbd>, this resets the selection.
With <kbd>Shift</kbd>, this toggles the selection.

You can then move the selected anchors by dragging one of the selected anchors,
or dragging an additional anchor to select while holding <kbd>Shift</kbd>.

### <img src="icons/pencil-alt.svg" width="18" alt="Pen Icon"> Pen Tool

The Pen Tool is a drawing mode that lets you draw freehand.
Expand Down

0 comments on commit 5d22d3e

Please sign in to comment.