From 69cc78ad3e86120d0400c6b2aa58a68f7a153136 Mon Sep 17 00:00:00 2001 From: Erik Demaine Date: Wed, 9 Aug 2023 13:40:53 -0400 Subject: [PATCH] Polygon tool (#35) --- CHANGELOG.md | 4 +- client/DrawApp.coffee | 3 +- client/Page.coffee | 13 +++-- client/RenderObjects.coffee | 3 +- client/Selection.coffee | 2 +- client/lib/icons.coffee | 2 + client/tools/history.coffee | 5 +- client/tools/modes.coffee | 110 +++++++++++++++++++++++++++++++++++- client/tools/tools.coffee | 1 + doc/README.md | 50 ++++++++++++---- doc/icons/draw-polygon.svg | 5 ++ lib/objects.coffee | 17 +++++- 12 files changed, 188 insertions(+), 27 deletions(-) create mode 100644 doc/icons/draw-polygon.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index 6710056..deb4b4a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,10 @@ instead of version numbers. ## 2023-08-09 -* Fix PDF export of LaTeX text and arrows +* New polygonal line tool! + [[#35](https://github.com/edemaine/cocreate/issues/35)] * Reduce zoom speed via Ctrl + mouse wheel by 4x +* Fix PDF export of LaTeX text and arrows ## 2023-05-01 diff --git a/client/DrawApp.coffee b/client/DrawApp.coffee index 5dfa942..ca75544 100644 --- a/client/DrawApp.coffee +++ b/client/DrawApp.coffee @@ -238,6 +238,7 @@ export DrawAppRoom = -> onCleanup dom.listen window, keydown: (e) -> return if e.target.classList.contains 'modal' + return if tools[currentTool()].key? e switch e.key when 'z', 'Z' if e.ctrlKey or e.metaKey @@ -487,7 +488,7 @@ export DrawAppRoom = -> {### Filter+ from Dark Reader, https://github.com/darkreader/darkreader/blob/7f047b20909b09b8cdb3e45550d0c586abeb98a4/src/generators/utils/matrix.ts#L42 ###} + style={"color-interpolation-filters": "srgb"}> diff --git a/client/Page.coffee b/client/Page.coffee index 30ec267..3f7af6f 100644 --- a/client/Page.coffee +++ b/client/Page.coffee @@ -69,12 +69,13 @@ export class Page options.start = old.pts.length else ## For other types such as `poly`, `rect`, `ellipse`, do a diff - for start in [0...old.pts.length] - oldPt = old.pts[start] - newPt = obj.pts[start] - if oldPt.x != newPt.x or oldPt.y != newPt.y or oldPt.w != newPt.w - break - options.start = start + if old.pts.length == obj.pts.length + for start in [0...old.pts.length] + oldPt = old.pts[start] + newPt = obj.pts[start] + if oldPt.x != newPt.x or oldPt.y != newPt.y or oldPt.w != newPt.w + break + options.start = start for own key of obj when key not of noDiff options[key] = obj[key] != old[key] @render.render obj, options diff --git a/client/RenderObjects.coffee b/client/RenderObjects.coffee index ce0cacd..b8cbec1 100644 --- a/client/RenderObjects.coffee +++ b/client/RenderObjects.coffee @@ -204,7 +204,8 @@ export class RenderObjects 'stroke-dasharray': scaleDash obj.dash, obj.width 'stroke-linecap': 'round' 'stroke-linejoin': 'round' - fill: 'none' + fill: obj.fill or 'none' + 'fill-opacity': obj.opacity 'marker-start': if obj.arrowStart then arrow 'marker-end': if obj.arrowEnd then arrow poly diff --git a/client/Selection.coffee b/client/Selection.coffee index a0b9f24..ebe026e 100644 --- a/client/Selection.coffee +++ b/client/Selection.coffee @@ -216,7 +216,7 @@ export class Selection when 'width' continue unless obj.type in ['pen', 'poly', 'rect', 'ellipse'] when 'fill' - continue unless obj.type in ['rect', 'ellipse'] + continue unless obj.type in ['poly', 'rect', 'ellipse'] when 'color' continue unless obj.type in ['pen', 'poly', 'rect', 'ellipse', 'text'] when 'arrowStart', 'arrowEnd' diff --git a/client/lib/icons.coffee b/client/lib/icons.coffee index 8e0cd54..8426380 100644 --- a/client/lib/icons.coffee +++ b/client/lib/icons.coffee @@ -90,6 +90,8 @@ export icons = '' 'download-pdf': # like download-svg but with "PDF" text '' + 'draw-polygon': + '' ellipse: # edited rect below '' 'ellipse-fill': # center of above diff --git a/client/tools/history.coffee b/client/tools/history.coffee index a12f78c..f277daa 100644 --- a/client/tools/history.coffee +++ b/client/tools/history.coffee @@ -80,7 +80,10 @@ defineTool switch key when 'pts' for subkey, subvalue of value - obj[key][subkey] = subvalue + if subvalue? + obj[key][subkey] = subvalue + else + obj[key].splice subkey, 1 else obj[key] = value toRender.add obj.id diff --git a/client/tools/modes.coffee b/client/tools/modes.coffee index 763a701..d3aa2bf 100644 --- a/client/tools/modes.coffee +++ b/client/tools/modes.coffee @@ -538,9 +538,7 @@ rectLikeTool = (type, fillable, constrain) -> pointers[e.pointerId].alt = e.altKey return if JSON.stringify(last) == JSON.stringify(pts) pointers[e.pointerId].last = pts - edit - id: id - pts: pts + edit {id, pts} defineTool Object.assign rectLikeTool('poly', false, orthogonalPoint), name: 'segment' @@ -568,6 +566,112 @@ defineTool Object.assign rectLikeTool('ellipse', true, equalXYPoint), help: <>Draw axis-aligned ellipsis inside rectangle between endpoints (drag). Hold Shift to constrain to circle, {Alt} to center at first point. Click without dragging to center a circular dot proportional to line width. hotkey: 'o' +defineTool + name: 'poly' + category: 'mode' + icon: 'draw-polygon' + hotspot: [0.1875, 0.83] + hotkey: ['P', 'g'] + help: <>Draw polygonal line by clicking successive points. Right click or double click last point; or type Escape or switch modes to finish. Backspace/Delete removes last vertex. Hold Shift to constrain to horizontal/vertical. + stop: -> + return unless pointers.active? + ## Remove last vertex by simulating Escape key + @key key: 'Escape' + return unless pointers.active? + @finish() + finish: (del) -> + ## Finish and optionally delete current poly + return unless pointers.active? + {id, prev, last, edit} = pointers.active + if JSON.stringify(prev) == JSON.stringify(last) # duplicate last vertex + edit + id: id + pts: + "#{pointers.active.index}": null + edit.flush() + obj = Objects.findOne id + if del or obj.pts.length == 1 + Meteor.call 'objectDel', id + else + undoStack.push + type: 'new' + obj: obj + delete pointers.active + key: (e) -> + return unless pointers.active? + return unless e.key in ['Delete', 'Backspace', 'Escape'] + {id, index, last, edit} = pointers.active + ## If we hit Escape or Delete/Backspace and we've only clicked one point, + ## delete the object. + del = index <= 1 + unless del + if e.key == 'Escape' + ## If we hit Escape, pop the last vertex. + edit + id: id + pts: + "#{index}": null + else + ## If we hit Delete/Backspace, pop the previous vertex. + pointers.active.prev = Objects.findOne(id).pts[index-2] + edit + id: id + pts: + "#{index-1}": last + "#{index}": null + pointers.active.index-- + @finish del if del or e.key == 'Escape' + true + down: (e) -> + pt = maybeSnapPointToGrid currentBoard().eventToPoint e + if pointers.active? # new point in existing poly + {last, prev, edit} = pointers.active + pt = orthogonalPoint pt, e, prev + unless (same = (JSON.stringify(pt) == JSON.stringify(last))) + edit + id: pointers.active.id + pts: "#{pointers.active.index}": pt + if (dupe = (JSON.stringify(prev) == JSON.stringify(pt))) + ## Avoid duplicate vertex + pointers.active.last = pt + else + pointers.active.prev = pt + pointers.active.index++ + pointers.active.last = null # haven't sent new point yet + ## Right click or double click to finish + @finish() if e.button == 2 or (dupe and (same or not last?)) + else # new poly + object = + room: currentRoom().id + page: currentPage().id + type: 'poly' + pts: [pt] + color: currentColor() + width: currentWidth() + object.dash = currentDash() if currentDash() + object.fill = currentFill() if currentFillOn() + object.opacity = currentOpacity() if currentOpacityOn() + object.arrowStart = currentArrowStart() if currentArrowStart() + object.arrowEnd = currentArrowEnd() if currentArrowEnd() + pointers.active = + origin: pt # TODO: snap to close polygon + prev: pt # previous fixed vertex + last: null # last sent coordinates for current vertex + index: 1 # current vertex index in pts array + id: Meteor.apply 'objectNew', [object], returnStubValue: true + edit: throttle.method 'objectEdit', ([edit1], [edit2]) -> + edit2.pts = Object.assign {}, edit1.pts, edit2.pts + [edit2] + move: (e) -> + return unless pointers.active? + {id, index, last, prev, edit} = pointers.active + pt = maybeSnapPointToGrid currentBoard().eventToPoint e + pt = orthogonalPoint pt, e, prev + return if JSON.stringify(pt) == JSON.stringify(last) + pts = "#{index}": pt + edit {id, pts} + pointers.active.last = pt + defineTool name: 'eraser' category: 'mode' diff --git a/client/tools/tools.coffee b/client/tools/tools.coffee index b720edb..c5ea0ae 100644 --- a/client/tools/tools.coffee +++ b/client/tools/tools.coffee @@ -34,6 +34,7 @@ export drawingTools = segment: true rect: true ellipse: true + poly: true text: true export historyTools = pan: true diff --git a/doc/README.md b/doc/README.md index 4cd1fc1..1acc291 100644 --- a/doc/README.md +++ b/doc/README.md @@ -256,8 +256,9 @@ geometry of existing drawn objects. Specifically, you can drag the points that define a [Segment Icon Segment](#-segment-tool), [Rectangle Icon Rectangle](#-rectangle-tool), +[Ellipse Icon Ellipse](#-ellipse-tool), or -[Ellipse Icon Ellipse](#-ellipse-tool). +[Polygon Icon Polygon](#-polygon-tool). Switching to this mode displays small squares at all the draggable points, called "anchors". @@ -351,6 +352,24 @@ You can use both modifiers at once to draw a circle centered at a point, and you can play with these toggles while you're drawing an ellipse. (This behavior matches Adobe Illustrator.) +### Polygon Icon Polygon Tool + +The Polygon Tool is a drawing mode that lets you draw polygonal lines +(open or closed). +Click/tap successive points. +To finish your polygon (e.g. to start a new one), you have several options: + +* Right click the final point +* Double click the final point (easiest to achieve in grid snapping mode) +* Type Escape (which removes the floating point of the cursor) +* Switch drawing modes (which also removes the floating point of the cursor) + +You can also type Backscape or Delete to undo the +last clicked point, in case you make a mistake. + +If you hold Shift, then +the current line segment will be constrained to be horizontal or vertical. + ### Erase Icon Erase Tool The Erase Tool is a drawing mode that erases/deletes previously drawn objects. @@ -584,8 +603,10 @@ or [Triangular Grid Icon Triangular Grid Toggle](#-triangular-grid-toggle). (Even if the grid is invisible, the last chosen grid defines the snapping behavior.) [Segment Icon Segments](#-segment-tool), -[Rectangle Icon Rectangles](#-rectangle-tool), and -[Ellipse Icon Ellipses](#-ellipse-tool) +[Rectangle Icon Rectangles](#-rectangle-tool), +[Ellipse Icon Ellipses](#-ellipse-tool), +and +[Polygon Icon Polygons](#-polygon-tool) will have their defining points rounded to the nearest grid point. The [Pen Icon Pen Tool](#-pen-tool) is unaffected by grid snapping. @@ -838,7 +859,9 @@ These buttons control the outline/stroke width of objects with an outline: [Pen Icon Pen](#-pen-tool), [Segment Icon Segments](#-segment-tool), [Rectangle Icon Rectangles](#-rectangle-tool), -[Ellipse Icon Ellipses](#-ellipse-tool). +[Ellipse Icon Ellipses](#-ellipse-tool), +and +[Polygon Icon Polygons](#-polygon-tool). Currently, you have seven integral choices. ### Font Size @@ -858,7 +881,9 @@ These buttons control the outline/stroke style of objects with an outline: [Pen Icon Pen](#-pen-tool), [Segment Icon Segments](#-segment-tool), [Rectangle Icon Rectangles](#-rectangle-tool), -[Ellipse Icon Ellipses](#-ellipse-tool). +[Ellipse Icon Ellipses](#-ellipse-tool), +and +[Polygon Icon Polygons](#-polygon-tool). Currently, you have four choices: solid, dotted, dashed, and dot-dashed. ### Start-Arrow Icon End-Arrow Icon Arrow Toggles @@ -881,9 +906,10 @@ Click/tap the button to switch between the normal fully opaque mode In partially transparent/opaque mode, three additional buttons will display for selecting among 25%, 50%, or 75% opacity. This setting will affect both stroke and fill opacity for fillable objects: -[Rectangle Icon Rectangles](#-rectangle-tool) +[Rectangle Icon Rectangles](#-rectangle-tool), +[Ellipse Icon Ellipses](#-ellipse-tool), and -[Ellipse Icon Ellipses](#-ellipse-tool). +[Polygon Icon Polygons](#-polygon-tool). Currently, [Pen Icon Pen](#-pen-tool) @@ -902,9 +928,10 @@ set by Shift-clicking on a color in the [Color Palette](#color-palette). This toggle only affects objects that support fill: -[Rectangle Icon Rectangles](#-rectangle-tool) +[Rectangle Icon Rectangles](#-rectangle-tool), +[Ellipse Icon Ellipses](#-ellipse-tool), and -[Ellipse Icon Ellipses](#-ellipse-tool). +[Polygon Icon Polygons](#-polygon-tool). ### Color Palette @@ -913,9 +940,10 @@ These buttons normally control the **outline/stroke** color of objects. If you hold down Shift on your keyboard when choosing a color, you control the **fill** color of objects that support fill: -[Rectangle Icon Rectangles](#-rectangle-tool) +[Rectangle Icon Rectangles](#-rectangle-tool), +[Ellipse Icon Ellipses](#-ellipse-tool), and -[Ellipse Icon Ellipses](#-ellipse-tool). +[Polygon Icon Polygons](#-polygon-tool). When you choose a fill color, you automatically turn on [Fill Icon Fill](#-fill---no-fill-toggle). diff --git a/doc/icons/draw-polygon.svg b/doc/icons/draw-polygon.svg new file mode 100644 index 0000000..d0231fd --- /dev/null +++ b/doc/icons/draw-polygon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/lib/objects.coffee b/lib/objects.coffee index 3ccba30..9df2025 100644 --- a/lib/objects.coffee +++ b/lib/objects.coffee @@ -44,7 +44,7 @@ addAttributePattern = (pattern, type, edit) -> if type in ['pen', 'poly'] pattern.arrowStart = pattern.arrowEnd = Match.Optional Match.OneOf 'arrow', null # null for no arrowhead - if type in ['rect', 'ellipse'] + if type in ['rect', 'ellipse', 'poly'] pattern.fill = Match.Optional Match.OneOf String, null # null to turn off if type == 'text' pattern.text = optionalIfEdit String @@ -131,6 +131,8 @@ Meteor.methods return false unless /^\d+$/.test key key = parseInt key, 10 return false if key < 0 + if obj.type == 'poly' + continue if value == null check value, ptType switch obj.type when 'rect', 'ellipse' @@ -147,7 +149,14 @@ Meteor.methods switch key when 'pts' for subkey, subvalue of value - set["#{key}.#{subkey}"] = subvalue + if subvalue? + set["#{key}.#{subkey}"] = subvalue + else # null pops + obj = Objects.findOne id + if parseInt(subkey, 10) == obj?.pts?.length - 1 + pop = pts: 1 + else + throw new Meteor.Error "Attempt to pop non-last point" else set[key] = value unless @isSimulation @@ -156,6 +165,10 @@ Meteor.methods diff.type = 'edit' diff.updated = set.updated = new Date ObjectsDiff.insert diff + if pop? + Objects.update id, + $pop: pop + , channel: "rooms::#{obj.room}::objects" Objects.update id, $set: set , channel: "rooms::#{obj.room}::objects"