From 77f8f6677707bc9a6d18e384ae90d253917a3a40 Mon Sep 17 00:00:00 2001 From: Steve Ruiz Date: Sun, 21 Feb 2021 10:57:12 +0000 Subject: [PATCH] Adds pressure, fading. --- CHANGELOG.md | 7 +- package.json | 4 +- renderer/components/canvas.tsx | 7 +- renderer/components/controls.tsx | 13 +- renderer/hooks/useKeyboardEvents.tsx | 21 ++- renderer/hooks/usePointer.tsx | 10 +- renderer/lib/state.ts | 258 +++++++++++++++++++-------- yarn.lock | 28 ++- 8 files changed, 256 insertions(+), 92 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f45271..96d84b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,12 @@ +## 0.3.0 + +- Adds pressure strokes (`perfect-freehand`). +- Makes all strokes fade after a certain time has passed since the last stroke. + ## 0.2.1 - Notarized / signed mac app. ## 0.2.0 -- Adds changelog. +- Adds change log. diff --git a/package.json b/package.json index f9e273a..b506933 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { + "version": "0.3.0", "private": true, "name": "telestrator", "description": "A drawing app for streaming.", - "version": "0.2.1", "author": "Steve Ruiz @steveruizok", "homepage": "https://github.com/steveruizok/telestrator", "main": "app/background.js", @@ -20,6 +20,7 @@ "dependencies": { "@state-designer/react": "^1.4.5", "@types/lodash": "^4.14.168", + "@types/lodash-es": "^4.17.4", "@types/react": "^17.0.1", "@types/styled-components": "^5.1.7", "cardinal-spline": "^0.0.1", @@ -27,6 +28,7 @@ "electron-store": "^6.0.1", "framer-motion": "^3.3.0", "lodash-es": "^4.17.20", + "perfect-freehand": "^0.2.3", "react-feather": "^2.0.9", "styled-components": "^5.2.1" }, diff --git a/renderer/components/canvas.tsx b/renderer/components/canvas.tsx index b563f23..895095a 100644 --- a/renderer/components/canvas.tsx +++ b/renderer/components/canvas.tsx @@ -7,8 +7,11 @@ export default function App() { const rCanvasFrame = React.useRef() const rMarksCanvas = React.useRef() const rCurrentCanvas = React.useRef() - - const showCursor = useSelector((state) => state.isInAny("active")) + const showCursor = useSelector((state) => { + console.log(state.isIn("active")) + console.log(state.isIn("cursorVisible")) + return state.isIn("active", "cursorVisible") + }) React.useEffect(() => { state.send("LOADED", { diff --git a/renderer/components/controls.tsx b/renderer/components/controls.tsx index c364914..95c0eca 100644 --- a/renderer/components/controls.tsx +++ b/renderer/components/controls.tsx @@ -10,6 +10,7 @@ import { ArrowDownLeft, Lock, Unlock, + PenTool, } from "react-feather" export default function Controls() { @@ -17,6 +18,7 @@ export default function Controls() { const isFading = useSelector((state) => state.data.isFading) const selectedSize = useSelector((state) => state.data.size) const selectedColor = useSelector((state) => state.data.color) + const isPressure = useSelector((state) => state.data.pressure) const selectedTool = useSelector((state) => state.isIn("pencil") ? "pencil" @@ -59,9 +61,16 @@ export default function Controls() { ))} state.send("SELECTED_PENCIL")} + onDoubleClick={() => state.send("TOGGLED_PRESSURE")} + onClick={(e) => { + if (e.shiftKey) { + state.send("TOGGLED_PRESSURE") + } else { + state.send("SELECTED_PENCIL") + } + }} > - + {isPressure ? : } { function releaseControl(e: KeyboardEvent) { switch (e.key) { - case "Shift": { - state.send("TOGGLED_ASPECT_LOCK") - break - } - case "Meta": { - state.send("TOGGLED_FILL") - } case "1": case "2": case "3": @@ -30,10 +23,17 @@ export default function useKeyboardEvents() { if (e.metaKey) { state.send("TOGGLED_FADING") } + break + } + case "D": + case "P": { + state.send("TOGGLED_PRESSURE") + break } case "d": case "p": { state.send("SELECTED_PENCIL") + break } case "a": { @@ -70,6 +70,13 @@ export default function useKeyboardEvents() { state.send("DEACTIVATED") break } + case "Shift": { + state.send("TOGGLED_ASPECT_LOCK") + break + } + case "Meta": { + state.send("TOGGLED_FILL") + } } } diff --git a/renderer/hooks/usePointer.tsx b/renderer/hooks/usePointer.tsx index 158cff1..820abf7 100644 --- a/renderer/hooks/usePointer.tsx +++ b/renderer/hooks/usePointer.tsx @@ -6,12 +6,16 @@ interface MotionPointer { y: MotionValue dx: MotionValue dy: MotionValue + p: MotionValue + pointerType: string } export const mvPointer: MotionPointer = { x: motionValue(0), y: motionValue(0), dx: motionValue(0), dy: motionValue(0), + p: motionValue(0), + pointerType: "mouse", } interface PointerInfo { @@ -32,14 +36,12 @@ export default function usePointer( const dx = x - mvPointer.x.get() const dy = y - mvPointer.y.get() - // if (Math.hypot(dx, dy) < 8) { - // return - // } - mvPointer.x.set(x) mvPointer.y.set(y) mvPointer.dx.set(dx) mvPointer.dy.set(dy) + mvPointer.p.set(e.pressure) + mvPointer.pointerType = e.pointerType if (onMove) { onMove({ x, y, dx, dy }) diff --git a/renderer/lib/state.ts b/renderer/lib/state.ts index 89ff7ad..7721bf9 100644 --- a/renderer/lib/state.ts +++ b/renderer/lib/state.ts @@ -1,10 +1,12 @@ -import { remote, ipcRenderer } from "electron" +import { remote } from "electron" +import getPath from "perfect-freehand" import { RefObject } from "react" import { createSelectorHook, createState } from "@state-designer/react" -import cSpline from "cardinal-spline" import { mvPointer } from "hooks/usePointer" import * as defaultValues from "lib/defaults" +// TODO: Fades should begin after a certain amount of time has passed since the last mark was made. + enum MarkType { Freehand = "freehand", Ruled = "ruled", @@ -13,22 +15,20 @@ enum MarkType { Arrow = "arrow", } -interface Mark { +interface MarkBase { + pointerType: string + pressure: boolean type: MarkType size: number color: string eraser: boolean - points: number[] strength: number + points: number[][] } -interface CompleteMark { - type: MarkType - size: number - color: string - eraser: boolean - points: number[] - strength: number +interface Mark extends MarkBase {} + +interface CompleteMark extends MarkBase { path: Path2D } @@ -44,11 +44,14 @@ const state = createState({ data: { isFading: true, isDragging: false, - fadeDelay: 0.2, - fadeDuration: 1, + fadeDelay: 1, + fadeDuration: 0.5, + hideCursor: false, refs: undefined as Refs | undefined, color: "#42a6f6", size: 16, + pressure: true, + fading: [] as CompleteMark[], marks: [] as CompleteMark[], currentMark: undefined as Mark | undefined, redos: [] as CompleteMark[], @@ -180,11 +183,13 @@ const state = createState({ ], to: ["pencil"], }, + TOGGLED_PRESSURE: { do: "togglePressure" }, SELECTED_PENCIL: { to: "pencil" }, SELECTED_RECT: { to: "rect" }, SELECTED_ARROW: { to: "arrow" }, SELECTED_ELLIPSE: { to: "ellipse" }, SELECTED_ERASER: { to: "eraser" }, + STARTED_DRAWING: { do: "restoreFades" }, }, initial: "pencil", states: { @@ -224,7 +229,7 @@ const state = createState({ on: { STARTED_DRAWING: { get: "elements", - secretlyDo: ["beginEraserMark", "drawCurrentMark"], + do: ["beginEraserMark", "drawCurrentMark"], }, SELECTED_COLOR: { to: "pencil" }, }, @@ -236,7 +241,16 @@ const state = createState({ states: { notDrawing: { on: { - STARTED_DRAWING: { to: "drawing" }, + STARTED_DRAWING: { + if: "drawingWithPen", + to: ["cursorHidden", "drawing"], + else: { to: "drawing" }, + }, + }, + onEnter: { + wait: "fadeDelay", + do: "pushMarksToFading", + to: "hasMarks", }, }, drawing: { @@ -244,7 +258,7 @@ const state = createState({ on: { STOPPED_DRAWING: { get: "elements", - do: [ + secretlyDo: [ "completeMark", "clearCurrentMark", "drawPreviousMarks", @@ -266,6 +280,20 @@ const state = createState({ }, }, }, + cursor: { + initial: "cursorVisible", + states: { + cursorHidden: { + on: { + MOVED_CURSOR: { + unless: "drawingWithPen", + to: ["cursorVisible"], + }, + }, + }, + cursorVisible: {}, + }, + }, marks: { initial: "noMarks", states: { @@ -281,7 +309,7 @@ const state = createState({ onEnter: { get: "elements", if: "isLoaded", - secretlyDo: ["clearPreviousMarks", "drawPreviousMarks"], + secretlyDo: ["clearMarksCanvas", "drawPreviousMarks"], }, on: { TOGGLED_FADING: { do: "toggleFading", to: "notFading" }, @@ -294,7 +322,7 @@ const state = createState({ to: "notFading", }, { - unless: "hasMarks", + unless: ["hasFadingMarks", "hasMarks"], to: "noMarks", }, ], @@ -312,7 +340,7 @@ const state = createState({ repeat: { onRepeat: [ { - unless: ["hasMarks", "hasCurrentMark"], + unless: ["hasFadingMarks"], to: "noMarks", else: [ { @@ -320,7 +348,6 @@ const state = createState({ secretlyDo: ["fadeMarks", "removeFadedMarks"], }, { - if: "hasFadingMarks", secretlyDo: ["clearMarksCanvas", "drawPreviousMarks"], }, ], @@ -331,6 +358,11 @@ const state = createState({ }, }, }, + times: { + fadeDelay(data) { + return data.fadeDelay + }, + }, results: { elements(data) { if (!data.refs) return {} @@ -367,28 +399,34 @@ const state = createState({ return data.redos.length > 0 }, hasFadingMarks(data) { - return !!data.marks.find((mark) => mark.strength <= 1) + return data.fading.length > 0 + }, + drawingWithPen(data) { + const { pointerType } = getPointer() + return pointerType === "pen" }, }, actions: { // Fading toggleFading(data) { - data.isFading = !data.isFading + const { isFading, fading, marks } = data + data.isFading = !isFading if (!data.isFading) { - for (let mark of data.marks) { + marks.unshift(...fading) + for (let mark of marks) { mark.strength = 1 } } }, fadeMarks(data) { - const { fadeDuration } = data + const { fadeDuration, fading } = data const delta = 0.016 / fadeDuration - for (let mark of data.marks) { + for (let mark of fading) { mark.strength -= delta } }, removeFadedMarks(data) { - data.marks = data.marks.filter((mark) => mark.strength >= 0) + data.fading = data.fading.filter((mark) => mark.strength > 0) }, // Pointer Capture activate() { @@ -440,6 +478,9 @@ const state = createState({ data.size = keys[index] } }, + togglePressure(data) { + data.pressure = !data.pressure + }, setColor(data, payload) { data.color = payload }, @@ -447,6 +488,10 @@ const state = createState({ data.size = payload }, // Marks + pushMarksToFading(data) { + data.fading = [...data.marks] + data.marks = [] + }, clearPreviousMarks(data, payload, elements: Elements) { data.marks = [] }, @@ -475,92 +520,115 @@ const state = createState({ const ctx = cvs.getContext("2d") ctx.clearRect(0, 0, cvs.width, cvs.height) }, - beginPencilMark(data, payload: { pressure: number }) { - const { x, y } = getPointer() - let p = payload.pressure || 1 + hideOrShowCursor(data, payload) { + const { pointerType } = getPointer() + data.hideCursor = pointerType === "pen" + }, + restoreFades(data) { + // for (let mark of data.marks) { + // if (mark.strength > 0.75) { + // mark.strength = 1 * 2 + // } + // } + }, + beginPencilMark(data) { + const { x, y, pressure, pointerType } = getPointer() data.currentMark = { + pointerType, + pressure: data.pressure, type: MarkType.Freehand, size: data.size, color: data.color, - strength: 1 + data.fadeDelay, + strength: 1, eraser: false, - points: [x, y, x, y], + points: [[x, y, pressure]], } }, - beginEraserMark(data, payload: { pressure: number }) { - const { x, y } = getPointer() + beginEraserMark(data) { + const { x, y, pressure, pointerType } = getPointer() data.currentMark = { + pointerType, + pressure: data.pressure, type: MarkType.Freehand, size: data.size, color: data.color, eraser: true, - strength: 1 + data.fadeDelay, - points: [x, y, x, y], + strength: 1, + points: [[x, y, pressure]], } }, - beginRectMark(data, payload: { pressure: number }) { - const { x, y } = getPointer() + beginRectMark(data) { + const { x, y, pressure, pointerType } = getPointer() data.currentMark = { + pointerType, + pressure: data.pressure, type: MarkType.Rect, size: data.size, color: data.color, eraser: false, - strength: 1 + data.fadeDelay, - points: [x, y, x, y], + strength: 1, + points: [[x, y, pressure]], } }, - beginEllipseMark(data, payload: { pressure: number }) { - const { x, y } = getPointer() + beginEllipseMark(data) { + const { x, y, pressure, pointerType } = getPointer() data.currentMark = { + pointerType, + pressure: data.pressure, type: MarkType.Ellipse, size: data.size, color: data.color, eraser: false, - strength: 1 + data.fadeDelay, - points: [x, y, x, y], + strength: 1, + points: [[x, y, pressure]], } }, - beginArrowMark(data, payload: { pressure: number }) { - const { x, y } = getPointer() + beginArrowMark(data) { + const { x, y, pressure, pointerType } = getPointer() data.currentMark = { + pointerType, + pressure: data.pressure, type: MarkType.Arrow, size: data.size, color: data.color, eraser: false, - strength: 1 + data.fadeDelay, - points: [x, y, x, y], + strength: 1, + points: [[x, y, pressure]], } }, - addPointToMark(data, payload: { pressure: number }) { + addPointToMark(data) { const { points, type } = data.currentMark - const { x, y } = getPointer() + const { x, y, pressure, pointerType } = getPointer() + + if (pointerType !== data.currentMark.pointerType) { + return + } switch (type) { case MarkType.Freehand: { - points.push(x, y) + points.push([x, y, pressure]) break } case MarkType.Arrow: case MarkType.Ellipse: case MarkType.Rect: { - points[2] = x - points[3] = y + points[1] = [x, y, pressure] break } } }, - completeMark(data, payload: { pressure: number }) { + completeMark(data) { const { type } = data.currentMark let path: Path2D switch (type) { case MarkType.Freehand: { - path = getFreehandPath(data.currentMark) + path = getFreehandPath(data.currentMark, data.pressure) break } case MarkType.Ellipse: { @@ -593,14 +661,20 @@ const state = createState({ ctx.lineCap = "round" ctx.lineJoin = "round" - for (let mark of data.marks) { + for (let mark of [...data.marks, ...data.fading]) { + ctx.fillStyle = mark.color ctx.strokeStyle = mark.color ctx.lineWidth = mark.size ctx.globalCompositeOperation = mark.eraser ? "destination-out" : "source-over" ctx.globalAlpha = easeOutQuad(Math.min(1, mark.strength)) - ctx.stroke(mark.path) + + if (mark.type === MarkType.Freehand) { + ctx.fill(mark.path) + } else { + ctx.stroke(mark.path) + } } } }, @@ -619,7 +693,7 @@ const state = createState({ switch (type) { case MarkType.Freehand: { - path = getFreehandPath(data.currentMark) + path = getFreehandPath(data.currentMark, data.pressure) break } case MarkType.Ellipse: { @@ -647,12 +721,20 @@ const state = createState({ ctx.strokeStyle = "rgba(144, 144, 144, 1)" } - ctx.stroke(path) + if (mark.type === MarkType.Freehand) { + ctx.fill(path) + } else { + ctx.stroke(path) + } } }, // Undos and redos undoMark(data) { - data.redos.push(data.marks.pop()) + if (data.marks.length > 0) { + data.redos.push(data.marks.pop()) + } else if (data.fading.length > 0) { + data.redos.push(data.fading.pop()) + } }, redoMark(data) { data.marks.push(data.redos.pop()) @@ -664,23 +746,49 @@ const state = createState({ }) // Draw a mark onto the given canvas -function getFreehandPath(mark: Mark) { - const [x, y, ...rest] = cSpline(mark.points) +function getFreehandPath(mark: Mark, isPressure: boolean) { + const { points } = mark - const path = new Path2D() - path.moveTo(x, y) - for (let i = 0; i < rest.length - 1; i += 2) { - path.lineTo(rest[i], rest[i + 1]) + if (points.length < 10) { + const path = new Path2D() + const [x, y] = points[points.length - 1] + path.moveTo(x, y) + path.ellipse(x, y, mark.size / 2, mark.size / 2, 0, Math.PI * 2, 0) + return path } + const path = new Path2D( + getPath(points, { + minSize: mark.size * 0.382, + maxSize: isPressure ? mark.size : mark.size / 2, + pressure: isPressure, + simulatePressure: mark.pointerType !== "pen", + }) + ) return path + + // points.unshift(points[0]) + + // const [x, y, ...rest] = cSpline( + // mark.points.reduce((acc, [x, y]) => { + // acc.push(x, y) + // return acc + // }, []) + // ) + // console.log(x, y, rest) + // const path = new Path2D() + // path.moveTo(x, y) + // for (let i = 0; i < rest.length - 2; i += 2) { + // path.lineTo(rest[i], rest[i + 1]) + // } + // return path } function getRectPath(mark: Mark) { const { points } = mark - const x0 = Math.min(points[0], points[2]) - const y0 = Math.min(points[1], points[3]) - const x1 = Math.max(points[0], points[2]) - const y1 = Math.max(points[1], points[3]) + const x0 = Math.min(points[0][0], points[1][0]) + const y0 = Math.min(points[0][1], points[1][1]) + const x1 = Math.max(points[0][0], points[1][0]) + const y1 = Math.max(points[0][1], points[1][1]) const path = new Path2D() path.rect(x0, y0, x1 - x0, y1 - y0) @@ -689,10 +797,10 @@ function getRectPath(mark: Mark) { function getEllipsePath(mark: Mark) { const { points } = mark - const x0 = Math.min(points[0], points[2]) - const y0 = Math.min(points[1], points[3]) - const x1 = Math.max(points[0], points[2]) - const y1 = Math.max(points[1], points[3]) + const x0 = Math.min(points[0][0], points[1][0]) + const y0 = Math.min(points[0][1], points[1][1]) + const x1 = Math.max(points[0][0], points[1][0]) + const y1 = Math.max(points[0][1], points[1][1]) const w = x1 - x0 const h = y1 - y0 const cx = x0 + w / 2 @@ -705,7 +813,7 @@ function getEllipsePath(mark: Mark) { function getArrowPath(mark: Mark) { const { points } = mark - const [x0, y0, x1, y1] = points + const [[x0, y0], [x1, y1]] = points const angle = Math.atan2(y1 - y0, x1 - x0) const distance = Math.hypot(y1 - y0, x1 - x0) const leg = (Math.min(distance / 2, 48) * mark.size) / 16 @@ -729,6 +837,8 @@ export function getPointer() { y: mvPointer.y.get(), dx: mvPointer.dx.get(), dy: mvPointer.dy.get(), + pressure: mvPointer.p.get(), + pointerType: mvPointer.pointerType, } } diff --git a/yarn.lock b/yarn.lock index e3aedd9..1fdcab0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1120,7 +1120,14 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.7.tgz#98a993516c859eb0d5c4c8f098317a9ea68db9ad" integrity sha512-cxWFQVseBm6O9Gbw1IWb8r6OS4OhSt3hPZLkFApLjM8TEXROBuQGLAH2i2gZpcXdLBIrpXuTDhH7Vbm1iXmNGA== -"@types/lodash@^4.14.168": +"@types/lodash-es@^4.17.4": + version "4.17.4" + resolved "https://registry.yarnpkg.com/@types/lodash-es/-/lodash-es-4.17.4.tgz#b2e440d2bf8a93584a9fd798452ec497986c9b97" + integrity sha512-BBz79DCJbD2CVYZH67MBeHZRX++HF+5p8Mo5MzjZi64Wac39S3diedJYHZtScbRVf4DjZyN6LzA0SB0zy+HSSQ== + dependencies: + "@types/lodash" "*" + +"@types/lodash@*", "@types/lodash@^4.14.168": version "4.14.168" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.168.tgz#fe24632e79b7ade3f132891afff86caa5e5ce008" integrity sha512-oVfRvqHV/V6D1yifJbVRU3TMp8OT6o6BG+U9MkwuJ3U8/CsDHvalRpsxBqivn71ztOFZBTfJMvETbqHiaNSj7Q== @@ -4724,6 +4731,13 @@ pend@~1.2.0: resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA= +perfect-freehand@^0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/perfect-freehand/-/perfect-freehand-0.2.3.tgz#dc2edbabd81433f860f2a2f4f018f3480dda792f" + integrity sha512-6u+RLAYa1wzQVHC4HxW59flOUyoeNTS9Fy46LESiOVgA9en75bLDqnsrC5/zFK0XKIAsylGdLGxnrhmT1x7rgQ== + dependencies: + polygon-clipping "^0.15.2" + picomatch@^2.0.4, picomatch@^2.2.1: version "2.2.2" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad" @@ -4772,6 +4786,13 @@ pnp-webpack-plugin@1.6.4: dependencies: ts-pnp "^1.1.6" +polygon-clipping@^0.15.2: + version "0.15.2" + resolved "https://registry.yarnpkg.com/polygon-clipping/-/polygon-clipping-0.15.2.tgz#076199182bf23a4a6cf6c7003f3b81ecf30b2cb8" + integrity sha512-qsUFQSY4nA++1/b76dy0BJGwL0FZAk05Y4hZprctLIhAddE8KUUr3TxIF4sAxIQtjH9xvaBe3raaRQrcSI4wlA== + dependencies: + splaytree "^3.1.0" + popmotion@^9.1.0: version "9.1.0" resolved "https://registry.yarnpkg.com/popmotion/-/popmotion-9.1.0.tgz#4360d06bd18ce8baa8f9284ecec7d55344af6325" @@ -5557,6 +5578,11 @@ spdx-license-ids@^3.0.0: resolved "https://registry.yarnpkg.com/spdx-license-ids/-/spdx-license-ids-3.0.7.tgz#e9c18a410e5ed7e12442a549fbd8afa767038d65" integrity sha512-U+MTEOO0AiDzxwFvoa4JVnMV6mZlJKk2sBLt90s7G0Gd0Mlknc7kxEn3nuDPNZRta7O2uy8oLcZLVT+4sqNZHQ== +splaytree@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/splaytree/-/splaytree-3.1.0.tgz#17d4a0108a6da3627579690b7b847241e18ddec8" + integrity sha512-gvUGR7xnOy0fLKTCxDeUZYgU/I1Tdf8M/lM1Qrf8L2TIOR5ipZjGk02uYcdv0o2x7WjVRgpm3iS2clLyuVAt0Q== + split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2"