Skip to content

Commit

Permalink
Basic anchor dragging (#35)
Browse files Browse the repository at this point in the history
  • Loading branch information
edemaine committed Nov 12, 2022
1 parent eb98ee1 commit 102dedc
Show file tree
Hide file tree
Showing 11 changed files with 239 additions and 8 deletions.
10 changes: 8 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,20 @@ To see every change with descriptions aimed at developers, see
As a continuously updated web app, Cocreate uses dates
instead of version numbers.

## 2022-11-12

* New "anchor select" tool with basic support for dragging anchors
to reshape segments, rectangles, and ellipses.
[[#214](https://github.com/edemaine/cocreate/issues/214)]

## 2022-08-08

* Fix bug in vertical alignment of text with LaTeX formulas in Firefox
* Fix bug in vertical alignment of text with LaTeX formulas in Firefox.
[[#199](https://github.com/edemaine/cocreate/issues/199)]

## 2022-06-14

* Fix bug in Download PDF messing up Cocreate's layout
* Fix bug in Download PDF messing up Cocreate's layout.

## 2022-06-02

Expand Down
53 changes: 53 additions & 0 deletions client/Anchor.coffee
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
export anchorRadius = 4
export anchorStroke = 2
#export anchorVisualRadius = anchorRadius + anchorStroke / 2
export anchorObjectTypes = new Set ['poly', 'rect', 'ellipse']

export anchorsOf = (obj) ->
switch obj.type
when 'poly'
obj.pts
when 'rect', 'ellipse'
obj.pts.concat [
x: obj.pts[0].x
y: obj.pts[1].y
,
x: obj.pts[1].x
y: obj.pts[0].y
]
else
[]

pointMove = (moved, index, coords) ->
if moved[index].x == coords.x and moved[index].y == coords.y
false
else
moved[index] =
x: coords.x
y: coords.y
true

export anchorMove = (obj, moved, index, coords) ->
if index > 1 and obj.type in ['rect', 'ellipse', 'image']
if index == 2
pointMove(moved, 0, {x: coords.x, y: moved[0].y}) or
pointMove(moved, 1, {y: coords.y, x: moved[1].x})
else if index == 3
pointMove(moved, 1, {x: coords.x, y: moved[1].y}) or
pointMove(moved, 0, {y: coords.y, x: moved[0].x})
else
console.error "Invalid anchor index #{index}"
else if 0 <= index < obj.pts.length
pointMove moved, index, coords
else
console.error "Out-of-bounds anchor index #{index}"

#export anchorIntersects = (point, anchor) ->
# Math.abs(point.x - anchor.x) <= anchorVisualRadius and
# Math.abs(point.y - anchor.y) <= anchorVisualRadius

export anchorFromEvent = (e) ->
for elt in document.elementsFromPoint e.clientX, e.clientY
if elt.getAttribute('class') == 'anchor'
return elt
return
2 changes: 1 addition & 1 deletion client/Collision.coffee
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {BBox} from './BBox'

intersectsSpecific =
intersectsSpecific =
pen: (query, obj) ->
## Untranslate query box if object is translated.
if obj.tx or obj.ty
Expand Down
38 changes: 37 additions & 1 deletion client/RenderObjects.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import dom from './lib/dom'
import icons from './lib/icons'
import {pointers} from './tools/modes'
import {tools} from './tools/defineTool'
import {anchorObjectTypes, anchorsOf, anchorRadius, anchorStroke} from './Anchor'
import {BBox, minSvgSize} from './BBox'
#import {DBVT} from './DBVT'

Expand Down Expand Up @@ -483,6 +484,7 @@ export class RenderObjects
elt.removeAttribute 'transform'
id = @id obj
@updated id, transformOnly
@renderAnchors id, obj if @anchors?
## DBVT update
#unless @bbox[id]? # new object
# @dbvt.insert id, @bbox[id] =
Expand All @@ -501,7 +503,7 @@ export class RenderObjects
# @bbox[id] = bbox
# @dbvt.move id, bbox
## BBox update (alternative to DBVT)
if obj.type == 'pen' and options? and
if obj.type == 'pen' and options?.start? and
not (options.width or options.tx or options.ty) # only points are added
@bbox[id] = @bbox[id].union(
BBox.fromPoints obj.pts[options.start...obj.pts.length]
Expand All @@ -522,6 +524,7 @@ export class RenderObjects
tools.text.stop() if id == pointers.text
@texDelete id if @texById[id]?
@updated id
@renderAnchors id if @anchors?[id]?
texDelete: (id) ->
for job in check = @texById[id]
delete job.texts[id]
Expand Down Expand Up @@ -554,6 +557,39 @@ export class RenderObjects
@board.selection.remove id
@board.highlighters[id]?.clear()

## Anchors
renderAnchors: (id, obj) ->
unless obj? # deletion
for anchor in @anchors[id] ? []
anchor.remove()
delete @anchors[id]
else
return unless anchorObjectTypes.has obj.type
@anchors[id] ?= []
for anchor, index in anchorsOf obj
unless (rect = @anchors[id][index])?
@root.appendChild rect = @anchors[id][index] = dom.create 'rect',
class: 'anchor'
width: 2 * anchorRadius
height: 2 * anchorRadius
'stroke-width': anchorStroke
'data-obj': id
'data-index': index
dom.attr rect,
x: anchor.x - anchorRadius + (obj.tx ? 0)
y: anchor.y - anchorRadius + (obj.ty ? 0)
showAnchors: (show) ->
if show
@anchors ?= {}
for id of @dom ? {}
obj = @board.findObject id
continue unless obj?
@renderAnchors id, obj
else
for id of @anchors ? {}
@renderAnchors id
@anchors = null

###
dot = (obj, p) ->
dom.create 'circle',
Expand Down
2 changes: 1 addition & 1 deletion client/Selection.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export class Highlighter
return unless Objects.findOne(target.dataset.id)?.type == @type
target
highlight: (target) ->
## `target` should be the result of `findGroup` (or `eventTop`/`eventall`),
## `target` should be the result of `findGroup` (or `eventTop`/`eventAll`),
## so satisfies all above conditions.
@clear()
@target = target
Expand Down
4 changes: 4 additions & 0 deletions client/lib/icons.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export icons =
'<path d="M283.211 512c78.962 0 151.079-35.925 198.857-94.792 7.068-8.708-.639-21.43-11.562-19.35-124.203 23.654-238.262-71.576-238.262-196.954 0-72.222 38.662-138.635 101.498-174.394 9.686-5.512 7.25-20.197-3.756-22.23A258.156 258.156 0 0 0 283.211 0c-141.309 0-256 114.511-256 256 0 141.309 114.511 256 256 256z"/>'
'mouse-pointer':
'<path d="M398.2,329.1H292.1l55.8,136c3.9,9.4-0.6,20-9.4,24l-49.2,21.4c-9.2,4-19.4-0.6-23.3-9.7l-53.1-129.1l-86.7,89.1C114.7,472.7,96,463.6,96,448V18.3c0-16.4,19.9-24.4,30.3-12.9L410.7,298C422.2,309.2,413.7,329.1,398.2,329.1L398.2,329.1z"/>'
## v6 arrow-pointer:
#'<path d="M96,55.2V426c0,12.2,9.9,22,22,22c6.3,0,12.4-2.7,16.6-7.5l82.6-94.5l58.1,116.3c7.9,15.8,27.1,22.2,42.9,14.3s22.2-27.1,14.3-42.9L275.8,320h118.1c12.2,0,22.1-9.9,22.1-22.1c0-6.3-2.7-12.3-7.4-16.5l-274-243.5c-4.3-3.8-9.7-5.9-15.4-5.9C106.4,32,96,42.4,96,55.2z"/>'
'pencil-alt':
'<path d="M497.9 142.1l-46.1 46.1c-4.7 4.7-12.3 4.7-17 0l-111-111c-4.7-4.7-4.7-12.3 0-17l46.1-46.1c18.7-18.7 49.1-18.7 67.9 0l60.1 60.1c18.8 18.7 18.8 49.1 0 67.9zM284.2 99.8L21.6 362.4.4 483.9c-2.9 16.4 11.4 30.6 27.8 27.8l121.5-21.3 262.6-262.6c4.7-4.7 4.7-12.3 0-17l-111-111c-4.8-4.7-12.4-4.7-17.1 0zM124.1 339.9c-5.5-5.5-5.5-14.3 0-19.8l154-154c5.5-5.5 14.3-5.5 19.8 0s5.5 14.3 0 19.8l-154 154c-5.5 5.5-14.3 5.5-19.8 0zM88 424h48v36.3l-64.5 11.3-31.1-31.1L51.7 376H88v48z"/>'
plus:
Expand Down Expand Up @@ -63,6 +65,8 @@ export icons =
## Significantly modified icons, with source SVG (before outlining,
## compounding strokes, and/or booleans) in subdirectory `iconsrc`:

'anchor-select': # mouse-pointer plus anchor rect, scaled down to fit
'<path d="M412.6,327.8L238.7,149V0.2H72.3v166.4h69.4v290.3c0,13.4,16.1,21.3,26,11l74.6-76.7l45.7,111.1c3.4,7.8,12.1,11.8,20.1,8.3l42.3-18.4c7.6-3.4,11.4-12.6,8.1-20.7l-48-117h91.3C415.2,354.6,422.5,337.5,412.6,327.8z M117.3,121.6V45.2h76.4v57.4L167.8,76c-9-9.9-26.1-3-26.1,11.1v34.5H117.3z"/>'
'chevron-left-square': # chevron-left scaled to fit in plus-square's square
'<path d="M480,80v352c0,26.5-21.5,48-48,48H80c-26.5,0-48-21.5-48-48V80c0-26.5,21.5-48,48-48h352C458.5,32,480,53.5,480,80zM432,426V86c0-3.3-2.7-6-6-6H86c-3.3,0-6,2.7-6,6v340c0,3.3,2.7,6,6,6h340C429.3,432,432,429.3,432,426z M168.7,244.6l136-136c6.6-6.6,17.2-6.6,23.8,0l15.9,15.9c6.6,6.6,6.6,17.2,0,23.7L236.5,256.5l107.8,108.3c6.5,6.6,6.5,17.2,0,23.7l-15.9,15.9c-6.6,6.6-17.2,6.6-23.8,0l-136-136C162.1,261.8,162.1,251.2,168.7,244.6z"/>'
'chevron-right-square': # chevron-right scaled to fit in plus-square's square
Expand Down
8 changes: 8 additions & 0 deletions client/lib/iconsrc/anchor-select.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions client/main.styl
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,12 @@ svg
.selected
opacity: 0.666
pointer-events: none
.anchor
stroke: rgba(0,0,0,0.666)
fill: rgba(0,0,0,0.25)
&:hover
stroke: rgba(0,0,0,0.888)
fill: rgba(0,0,0,0.5)
line.cursor
opacity: 0.5
stroke: black
Expand Down
104 changes: 101 additions & 3 deletions client/tools/modes.coffee
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {defineTool} from './defineTool'
import {tryAddImageUrl} from './image'
import {tools, selectTool} from './tools'
import {currentWidth} from './width'
import {anchorFromEvent, anchorMove, anchorsOf} from '../Anchor'
import {currentBoard, mainBoard, currentRoom, currentPage, currentTool, currentColor, currentFill, currentFillOn, currentFontSize, currentOpacity, currentOpacityOn} from '../AppState'
import {maybeSnapPointToGrid} from '../Grid'
import {Highlighter, highlighterClear} from '../Selection'
Expand Down Expand Up @@ -131,6 +132,7 @@ defineTool
pointers.firstClick = {}
stop: ->
delete pointers.objects
delete pointers.firstClick
down: (e) ->
selection = currentBoard().selection
pointers[e.pointerId] ?= new Highlighter currentBoard()
Expand Down Expand Up @@ -192,13 +194,12 @@ defineTool
if h?.selector? # finished rectangular drag
board = currentBoard()
query = BBox.fromPoints [h.start, board.eventToPoint(e)]
selection = board.selection
render = board.render
{render, selection} = board
# render is undefined when history mode starts but hasn't been advanced
for id of render?.dom ? {}
#for id from render.dbvt.query query
bbox = render.bbox[id]
continue unless query.intersects bbox # quick filter without DBVT
continue unless query.intersects bbox # quick filter
obj = board.findObject id
continue unless obj?
if intersects query, obj, bbox
Expand Down Expand Up @@ -276,6 +277,103 @@ defineTool
select: (ids) ->
currentBoard().selection.addId id for id in ids

defineTool
name: 'anchor'
category: 'mode'
icon: 'anchor-select'
hotspot: [0.31, 0.16992]
help: "Drag anchor handles to reshape lines, rectangles, and ellipses."
hotkey: 'a'
start: ->
currentBoard().render.showAnchors true
stop: ->
currentBoard().render.showAnchors false
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
move: (e) ->
#pointers[e.pointerId] ?= new AnchorHighlighter currentBoard()
h = pointers[e.pointerId]
if h?.down
if eventDistanceThreshold h.down, e, dragDist
h.down = true
here = currentBoard().eventToPoint e
here = orthogonalPoint here, e, h.start
motion =
x: here.x - h.start.x
y: here.y - h.start.y
motion = maybeSnapPointToGrid motion
## Don't set h.moved out here in case no objects selected
diffs = {}
for id, obj of pointers.objects when obj?
continue unless obj.anchorIndices.length
h.moved ?= {}
h.moved[id] ?= obj.pts[..]
moved = false
for index in obj.anchorIndices
x = obj.anchors[index].x + motion.x
y = obj.anchors[index].y + motion.y
moved or= anchorMove obj, h.moved[id], index, {x, y}
continue unless moved
diffs[id] = {id, pts: h.moved[id]}
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
h.edit.flush()
undoStack.push
type: 'multi'
ops:
for id, obj of pointers.objects when obj?
type: 'edit'
id: id
before:
pts: obj.pts
after:
pts: h.moved[id]
###
else if h?.down != true # finished regular click without drag
objects = (id for id of pointers.objects)
if objects.length == 1 # clicked on an object
if (firstClick = pointers.firstClick[e.pointerId])? and
firstClick.id == objects[0] and
not eventDistanceThreshold(firstClick.e, e, doubleClickDist)
# double click on object
delete pointers.firstClick[e.pointerId]
if Objects.findOne(objects[0])?.type == 'text' # text object
selectTool 'text', select: focus: true
else
pointers.firstClick[e.pointerId] = firstClick =
id: objects[0]
e: e
## Expire firstClick and cleanup space after doubleClickTime
setTimeout ->
if firstClick == pointers.firstClick?[e.pointerId] # unchanged
delete pointers.firstClick[e.pointerId]
, doubleClickTime
###
#h?.clear()
delete pointers[e.pointerId]

defineTool
name: 'pen'
category: 'mode'
Expand Down
15 changes: 15 additions & 0 deletions doc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,21 @@ In addition, Cocreate offers two nonclipboard operations:
(If you prefer a pen interface for deleting objects, check out the
[<img src="icons/eraser.svg" width="18" alt="Erase Icon"> Erase Tool](#-erase-tool).)

### <img src="icons/anchor-select.svg" width="18" alt="Anchor Select Icon"> Anchor Select Tool

The Select Tool is another selection second that lets you manipulate the
geometry of existing drawn objects. Specifically, you can drag the points
that define a
[<img src="icons/segment.svg" width="18" alt="Segment Icon"> Segment](#-segment-tool),
[<img src="icons/rect.svg" width="18" alt="Rectangle Icon"> Rectangle](#-rectangle-tool),
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
purely horizontal or vertical.

### <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
5 changes: 5 additions & 0 deletions doc/icons/anchor-select.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 102dedc

Please sign in to comment.