diff --git a/__e2e_tests__/peer1.e2e.js b/__e2e_tests__/peer1.e2e.js index 5288777a9b0217bbbe147bfd41959662c995d45d..fd51fb34a8b0ba7d8fb7bc6671ae9a0b53c0090b 100644 --- a/__e2e_tests__/peer1.e2e.js +++ b/__e2e_tests__/peer1.e2e.js @@ -29,5 +29,6 @@ test("Clicking and dragging on canvas creates a single child element", async (t) .drag(canvas, 10, 10) .wait(syncTimeout) .expect(canvas.childElementCount) - .eql(1) + // first draw also creates last recognised path placeholder + .eql(2) }) diff --git a/__tests__/shape.test.js b/__tests__/shape.test.js new file mode 100644 index 0000000000000000000000000000000000000000..1f2b315555301288735e6e1d3ffab6c3d2e472a8 --- /dev/null +++ b/__tests__/shape.test.js @@ -0,0 +1,626 @@ +import recognizeFromPoints, { Shapes } from "../src/shapes" + +describe("shape recognition", () => { + describe("general", () => { + test("should return a shape description object", () => { + const points = [[0, 0]] + const result = recognizeFromPoints(points) + expect(result).not.toBeNull() + }) + }) + + describe("lines", () => { + test("should recognize a simple horizontal line", () => { + const points = [ + [0, 0], + [100, 0], + ] + const result = recognizeFromPoints(points) + expect(result.shape).toBe(Shapes.line) + expect(result.firstPoint).toStrictEqual([0, 0]) + expect(result.lastPoint).toStrictEqual([100, 0]) + }) + + test("should recognize a simple vertical line", () => { + const points = [ + [0, 50], + [0, -100], + ] + const result = recognizeFromPoints(points) + expect(result.shape).toBe(Shapes.line) + expect(result.firstPoint).toStrictEqual([0, 50]) + expect(result.lastPoint).toStrictEqual([0, -100]) + }) + + test("should recognize a slightly curve horizontal line", () => { + const points = [ + [0, 0], + [30, 5], + [100, 0], + ] + const result = recognizeFromPoints(points) + expect(result.shape).toBe(Shapes.line) + }) + + test("should not recognize a heavily curved horizontal line", () => { + const points = [ + [0, 0], + [30, 30], + [100, -4], + ] + const result = recognizeFromPoints(points) + expect(result.shape).not.toBe(Shapes.line) + }) + + test("should recognize a long horizontal line", () => { + const points = [ + [48.675496688741724, 197.8062913907285], + [50.66225165562914, 197.8062913907285], + [53.64238410596026, 197.8062913907285], + [57.6158940397351, 197.8062913907285], + [61.58940397350993, 197.8062913907285], + [66.55629139072848, 197.8062913907285], + [71.52317880794702, 197.8062913907285], + [76.49006622516558, 197.8062913907285], + [77.48344370860927, 197.8062913907285], + [82.45033112582782, 197.8062913907285], + [84.43708609271523, 197.8062913907285], + [87.41721854304636, 197.8062913907285], + [88.41059602649007, 197.8062913907285], + [88.41059602649007, 197.8062913907285], + [89.40397350993378, 197.8062913907285], + ] + const result = recognizeFromPoints(points) + expect(result.shape).toBe(Shapes.line) + }) + + test("should recognize a long vertical line", () => { + const points = [ + [218.54304635761588, 84.56125827814569], + [218.54304635761588, 86.54801324503312], + [218.54304635761588, 87.54139072847681], + [218.54304635761588, 88.53476821192052], + [218.54304635761588, 90.52152317880794], + [218.54304635761588, 91.51490066225165], + [218.54304635761588, 92.50827814569537], + [218.54304635761588, 93.50165562913908], + [218.54304635761588, 94.49503311258277], + [218.54304635761588, 94.49503311258277], + [218.54304635761588, 95.4884105960265], + [218.54304635761588, 96.4817880794702], + [218.54304635761588, 97.4751655629139], + [218.54304635761588, 98.46854304635761], + [218.54304635761588, 100.45529801324503], + [218.54304635761588, 101.44867549668874], + [218.54304635761588, 103.43543046357617], + [218.54304635761588, 105.42218543046357], + [218.54304635761588, 108.4023178807947], + [218.54304635761588, 110.38907284768212], + [218.54304635761588, 112.37582781456953], + [218.54304635761588, 114.36258278145695], + [218.54304635761588, 116.34933774834438], + [218.54304635761588, 118.33609271523179], + [218.54304635761588, 119.32947019867551], + [218.54304635761588, 120.3228476821192], + [218.54304635761588, 122.30960264900662], + [218.54304635761588, 123.30298013245033], + [218.54304635761588, 124.29635761589404], + [218.54304635761588, 125.28973509933775], + [218.54304635761588, 125.28973509933775], + [218.54304635761588, 125.28973509933775], + [218.54304635761588, 126.28311258278147], + [218.54304635761588, 126.28311258278147], + [218.54304635761588, 127.27649006622516], + ] + const result = recognizeFromPoints(points) + expect(result.shape).toBe(Shapes.line) + }) + + test("should recognize a line at 20 degrees", () => { + const points = [ + [34.7682119205298, 268.3360927152318], + [34.7682119205298, 268.3360927152318], + [36.75496688741722, 268.3360927152318], + [37.74834437086093, 268.3360927152318], + [38.741721854304636, 268.3360927152318], + [39.735099337748345, 268.3360927152318], + [40.728476821192054, 268.3360927152318], + [41.721854304635755, 268.3360927152318], + [42.71523178807947, 268.3360927152318], + [43.70860927152318, 268.3360927152318], + [43.70860927152318, 268.3360927152318], + [44.70198675496689, 268.3360927152318], + [44.70198675496689, 268.3360927152318], + [45.6953642384106, 267.3427152317881], + [45.6953642384106, 267.3427152317881], + [46.6887417218543, 267.3427152317881], + [47.682119205298015, 267.3427152317881], + [48.675496688741724, 266.34933774834434], + [48.675496688741724, 266.34933774834434], + [49.66887417218543, 266.34933774834434], + [50.66225165562914, 266.34933774834434], + [51.65562913907284, 265.35596026490066], + [52.64900662251656, 265.35596026490066], + [52.64900662251656, 265.35596026490066], + [53.64238410596026, 265.35596026490066], + [54.63576158940397, 264.362582781457], + [54.63576158940397, 264.362582781457], + [55.629139072847686, 264.362582781457], + [55.629139072847686, 264.362582781457], + [57.6158940397351, 263.36920529801324], + [57.6158940397351, 263.36920529801324], + [58.609271523178805, 263.36920529801324], + [58.609271523178805, 263.36920529801324], + [59.602649006622514, 262.37582781456956], + [60.59602649006623, 262.37582781456956], + [61.58940397350993, 262.37582781456956], + [62.58278145695365, 261.3824503311258], + [63.57615894039735, 261.3824503311258], + [64.56953642384106, 260.3890728476821], + [66.55629139072848, 259.3956953642384], + [67.54966887417218, 259.3956953642384], + [68.54304635761589, 259.3956953642384], + [69.5364238410596, 258.4023178807947], + [69.5364238410596, 258.4023178807947], + [70.52980132450331, 258.4023178807947], + [71.52317880794702, 258.4023178807947], + [71.52317880794702, 257.408940397351], + [71.52317880794702, 257.408940397351], + [72.51655629139073, 257.408940397351], + [72.51655629139073, 257.408940397351], + [72.51655629139073, 257.408940397351], + [73.50993377483444, 257.408940397351], + [73.50993377483444, 257.408940397351], + [73.50993377483444, 257.408940397351], + [74.50331125827815, 257.408940397351], + [76.49006622516558, 257.408940397351], + [77.48344370860927, 257.408940397351], + [79.47019867549669, 256.4155629139073], + [80.4635761589404, 256.4155629139073], + [82.45033112582782, 255.42218543046357], + [83.44370860927151, 255.42218543046357], + [84.43708609271523, 255.42218543046357], + [85.43046357615894, 255.42218543046357], + [85.43046357615894, 254.42880794701986], + [86.42384105960264, 254.42880794701986], + [86.42384105960264, 254.42880794701986], + [87.41721854304636, 254.42880794701986], + [87.41721854304636, 254.42880794701986], + ] + + const result = recognizeFromPoints(points) + expect(result.shape).toBe(Shapes.line) + }) + + test("should recognize line 1", () => { + const points = [ + [380, 355], + [380, 356], + [381, 355], + [383, 354], + [386, 352], + [391, 349], + [395, 346], + [400, 343], + [405, 339], + [411, 335], + [416, 332], + [425, 325], + [433, 318], + [440, 312], + [447, 306], + [454, 300], + [462, 293], + [471, 283], + [479, 274], + [487, 266], + [498, 255], + [509, 244], + [520, 235], + [531, 226], + [539, 218], + [543, 215], + [550, 208], + [558, 203], + [563, 199], + [567, 196], + [571, 193], + [575, 190], + [577, 189], + [578, 188], + [579, 187], + [580, 187], + [580, 187], + [581, 187], + [581, 186], + ] + const result = recognizeFromPoints(points) + expect(result.shape).toBe(Shapes.line) + }) + + test("should recognize line 2", () => { + const points = [ + [648, 509], + [648, 509], + [648, 509], + [646, 509], + [641, 509], + [634, 509], + [628, 509], + [608, 509], + [591, 509], + [571, 509], + [554, 509], + [534, 509], + [513, 505], + [486, 499], + [462, 495], + [454, 494], + [433, 490], + [413, 488], + [396, 485], + [382, 485], + [368, 482], + [360, 482], + [356, 482], + [348, 479], + [341, 479], + [336, 478], + [331, 478], + [329, 478], + [327, 477], + [326, 476], + [326, 476], + [325, 476], + [325, 476], + [325, 476], + [325, 476], + [323, 476], + [322, 476], + [320, 476], + [319, 476], + [318, 476], + [316, 476], + [315, 475], + [315, 475], + [314, 475], + [314, 475], + [313, 475], + ] + const result = recognizeFromPoints(points) + expect(result.shape).toBe(Shapes.line) + }) + + test("should recognize line 3", () => { + const points = [ + [204, 590], + [205, 590], + [208, 584], + [219, 574], + [254, 534], + [276, 500], + [305, 456], + [334, 410], + [346, 388], + [376, 336], + [404, 284], + [430, 238], + [454, 197], + [458, 190], + [474, 160], + [483, 142], + [485, 138], + [492, 126], + [495, 121], + [498, 117], + [499, 115], + [500, 114], + [500, 114], + [500, 113], + ] + const result = recognizeFromPoints(points) + expect(result.shape).toBe(Shapes.line) + }) + }) + + describe("rectangles", () => { + test("should recognize simple rectangle", () => { + const points = [ + [0, 0], + [5, 0], + [10, 0], + [10, 5], + [10, 10], + [5, 10], + [0, 10], + ] + const result = recognizeFromPoints(points) + expect(result.shape).toBe(Shapes.rectangle) + }) + + test("should recognize rectangle with varying points", () => { + const points = [ + [0, 0], + [5, 1], + [10, 0], + [10, 6], + [10, 10], + [6, 10], + [0, 10], + ] + const result = recognizeFromPoints(points) + expect(result.shape).toBe(Shapes.rectangle) + }) + + test("should not recognize rectangle with non-rectangular points", () => { + const points = [ + [5, 1], + [23, 0], + [10, 54], + [0, 10], + ] + const result = recognizeFromPoints(points) + expect(result.shape).not.toBe(Shapes.rectangle) + }) + + test("should not recognize unclosed rectangle", () => { + const points = [ + [-10, -10], + [10, -10], + [10, 10], + ] + const result = recognizeFromPoints(points) + expect(result.shape).not.toBe(Shapes.rectangle) + }) + + test("should recognize almost-closed rectangle", () => { + const points = [ + [0, 0], + [5, 0], + [10, 0], + [10, 5], + [10, 10], + [5, 10], + [0, 8], + ] + const result = recognizeFromPoints(points) + expect(result.shape).toBe(Shapes.rectangle) + }) + + test("should recognize half-closed rectangle", () => { + const points = [ + [380, 503], + [379, 503], + [379, 503], + [379, 498], + [379, 491], + [379, 471], + [379, 468], + [379, 457], + [379, 440], + [379, 428], + [379, 412], + [379, 398], + [379, 384], + [379, 373], + [379, 369], + [379, 361], + [379, 354], + [379, 349], + [379, 346], + [379, 344], + [379, 343], + [379, 342], + [379, 341], + [381, 340], + [385, 340], + [389, 340], + [398, 340], + [407, 340], + [418, 340], + [429, 340], + [440, 340], + [451, 340], + [463, 340], + [476, 342], + [488, 343], + [502, 345], + [515, 348], + [520, 349], + [531, 350], + [540, 351], + [549, 352], + [552, 353], + [557, 354], + [558, 355], + [560, 355], + [560, 355], + [560, 355], + [561, 355], + [561, 356], + [561, 356], + [561, 356], + [561, 357], + [561, 360], + [561, 363], + [561, 368], + [561, 377], + [561, 388], + [561, 399], + [561, 406], + [561, 414], + [561, 423], + [561, 432], + [561, 441], + [561, 447], + [561, 452], + [561, 459], + [561, 463], + [561, 468], + [561, 471], + [561, 475], + [561, 478], + [561, 479], + [561, 480], + [561, 480], + [561, 481], + [561, 482], + [561, 482], + [561, 483], + [560, 483], + [560, 483], + [558, 484], + [556, 485], + [551, 487], + [546, 488], + [540, 490], + [533, 492], + [522, 493], + [513, 494], + [502, 496], + [491, 497], + [480, 497], + [466, 499], + [452, 500], + [447, 500], + [436, 500], + [427, 501], + [418, 501], + [412, 503], + [407, 503], + [402, 504], + [400, 504], + [398, 504], + [398, 504], + [397, 504], + [396, 504], + ] + const result = recognizeFromPoints(points) + expect(result.shape).toBe(Shapes.rectangle) + }) + + test("should recognize half-closed rectangle 2", () => { + const points = [ + [294, 477], + [293, 477], + [293, 477], + [293, 473], + [293, 469], + [293, 465], + [293, 459], + [293, 455], + [293, 451], + [293, 444], + [293, 437], + [293, 431], + [293, 423], + [293, 414], + [293, 403], + [293, 394], + [293, 391], + [293, 384], + [293, 379], + [293, 376], + [293, 373], + [293, 372], + [293, 371], + [293, 370], + [293, 370], + [293, 369], + [294, 369], + [295, 368], + [298, 368], + [303, 367], + [308, 366], + [317, 364], + [328, 362], + [341, 360], + [358, 357], + [375, 355], + [392, 352], + [412, 350], + [436, 348], + [457, 346], + [480, 344], + [501, 342], + [518, 342], + [535, 340], + [539, 339], + [551, 339], + [559, 338], + [568, 337], + [573, 337], + [576, 337], + [578, 337], + [579, 337], + [579, 337], + [580, 337], + [580, 337], + [581, 337], + [581, 338], + [581, 341], + [581, 346], + [581, 349], + [581, 358], + [579, 366], + [578, 373], + [576, 382], + [575, 386], + [571, 403], + [571, 407], + [568, 420], + [567, 424], + [565, 430], + [562, 437], + [561, 442], + [559, 447], + [558, 450], + [557, 452], + [556, 454], + [556, 456], + [555, 458], + [555, 459], + [554, 460], + [554, 461], + [553, 462], + [553, 462], + [553, 463], + [552, 463], + [552, 463], + [552, 464], + [552, 464], + [551, 464], + [550, 465], + [549, 465], + [544, 466], + [541, 467], + [534, 468], + [527, 469], + [521, 470], + [512, 472], + [509, 472], + [500, 474], + [493, 475], + [485, 476], + [478, 477], + [471, 478], + [465, 479], + [460, 480], + [454, 481], + [450, 482], + [446, 483], + [442, 483], + [439, 483], + [437, 484], + [436, 484], + [435, 484], + [434, 484], + [433, 484], + [433, 484], + [433, 484], + ] + const result = recognizeFromPoints(points) + expect(result.shape).toBe(Shapes.rectangle) + }) + }) +}) diff --git a/package-lock.json b/package-lock.json index 2417731f8da044be4423a51efbbefab9d595adff..b01e3a09bd57ddc784fde9f0da62101d5663545c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1088,6 +1088,11 @@ "integrity": "sha1-bI4obRHtdoMn+OYuzuhzU8o+eLg=", "dev": true }, + "array-flat-polyfill": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-flat-polyfill/-/array-flat-polyfill-1.0.1.tgz", + "integrity": "sha512-hfJmKupmQN0lwi0xG6FQ5U8Rd97RnIERplymOv/qpq8AoNKPPAnxJadjFA23FNWm88wykh9HmpLJUUwUtNU/iw==" + }, "array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -2358,9 +2363,9 @@ "integrity": "sha512-gaqbzQPqOoamawKg0LGVd7SzLgXS+JH61oWprSLH+P+abTczqJbhTR8CmJ2u9/bUYNmHTGJx/UEmn6doAvvuig==" }, "bluebird": { - "version": "3.7.1", - "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.1.tgz", - "integrity": "sha512-DdmyoGCleJnkbp3nkbxTLJ18rjDsE4yCggEwKNXkeV123sPNfOCYeDoeuOY+F2FrSjO1YXcTU+dsy96KMy+gcg==", + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", + "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==", "dev": true }, "bn.js": { @@ -3624,9 +3629,9 @@ "dev": true }, "electron-to-chromium": { - "version": "1.3.314", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.314.tgz", - "integrity": "sha512-IKDR/xCxKFhPts7h+VaSXS02Z1mznP3fli1BbXWXeN89i2gCzKraU8qLpEid8YzKcmZdZD3Mly3cn5/lY9xsBQ==", + "version": "1.3.320", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.320.tgz", + "integrity": "sha512-GVyRfGaKs/Vsf915WDaK5NG9vfud8nJFyapyQcrVS+sp8IeMpfml/YMvhthXsSOLlc0rzwdtnkNJE/+q4EPbTA==", "dev": true }, "elegant-spinner": { @@ -3907,9 +3912,9 @@ } }, "eslint": { - "version": "6.7.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.7.1.tgz", - "integrity": "sha512-UWzBS79pNcsDSxgxbdjkmzn/B6BhsXMfUaOHnNwyE8nD+Q6pyT96ow2MccVayUTV4yMid4qLhMiQaywctRkBLA==", + "version": "6.7.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-6.7.2.tgz", + "integrity": "sha512-qMlSWJaCSxDFr8fBPvJM9kJwbazrhNcBU3+DszDW1OlEwKBBRWsJc7NJFelvwQpanHCR14cOLD41x8Eqvo3Nng==", "dev": true, "requires": { "@babel/code-frame": "^7.0.0", @@ -8313,9 +8318,9 @@ "dev": true }, "psl": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/psl/-/psl-1.4.0.tgz", - "integrity": "sha512-HZzqCGPecFLyoRj5HLfuDSKYTJkAfB5thKBIkRHtGjWwY7p1dAyveIbXIq4tO0KYfDF2tHqPUgY9SDnGm00uFw==", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.5.0.tgz", + "integrity": "sha512-4vqUjKi2huMu1OJiLhi3jN6jeeKvMZdI1tYgi/njW5zV52jNLgSAZSdN16m9bJFe61/cT8ulmw4qFitV9QRsEA==", "dev": true }, "public-encrypt": { @@ -9714,9 +9719,9 @@ "dev": true }, "terser": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-4.4.0.tgz", - "integrity": "sha512-oDG16n2WKm27JO8h4y/w3iqBGAOSCtq7k8dRmrn4Wf9NouL0b2WpMHGChFGZq4nFAQy1FsNJrVQHfurXOSTmOA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-4.4.1.tgz", + "integrity": "sha512-e05giplw+8sIYh50qXYHZmr0b76O5dOSm9JwSDebGFLri4ItYzxsnumiAK+yuI56R+H7uIjT9KbVEKNkrprzHw==", "dev": true, "requires": { "commander": "^2.20.0", @@ -10060,9 +10065,9 @@ } }, "fast-glob": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.1.0.tgz", - "integrity": "sha512-TrUz3THiq2Vy3bjfQUB2wNyPdGBeGmdjbzzBLhfHN4YFurYptCKwGq/TfiRavbGywFRzY6U2CdmQ1zmsY5yYaw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.1.1.tgz", + "integrity": "sha512-nTCREpBY8w8r+boyFYAx21iL6faSsQynliPHM4Uf56SbkyohCNxpVPEH9xrF5TXKy+IsjkPUHDKiUkzBVRXn9g==", "dev": true, "requires": { "@nodelib/fs.stat": "^2.0.2", @@ -10710,9 +10715,9 @@ "dev": true }, "uglify-js": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.7.0.tgz", - "integrity": "sha512-PC/ee458NEMITe1OufAjal65i6lB58R1HWMRcxwvdz1UopW0DYqlRL3xdu3IcTvTXsB02CRHykidkTRL+A3hQA==", + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.7.1.tgz", + "integrity": "sha512-pnOF7jY82wdIhATVn87uUY/FHU+MDUdPLkmGFvGoclQmeu229eTkbG5gjGGBi3R7UuYYSEeYXY/TTY5j2aym2g==", "dev": true, "optional": true, "requires": { diff --git a/package.json b/package.json index 0d23c135e1320c1d063a58623d00563ae51c7884..049c501821dd458885def91a91755e717150d9fe 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ }, "dependencies": { "@ungap/event-target": "^0.1.0", + "array-flat-polyfill": "^1.0.1", "d3-shape": "^1.3.5", "dotenv": "^8.2.0", "express": "^4.17.1", diff --git a/public/index.html b/public/index.html index ca2ae6044aece77c536d264245695f3d3464af17..400687d87de0a059a312490f8a285c5f42e8a26f 100644 --- a/public/index.html +++ b/public/index.html @@ -60,7 +60,7 @@ <button id="room-connect">Connect <i class="fa fa-link"></i></button> </div> <div id="tools-panel"> - <button id="pen-tool" class="selected"> + <button id="pen-tool" class="selectable-tool selected"> <i class="fa fa-paint-brush"></i> </button> <div id="pen-properties" class="properties"> @@ -86,8 +86,7 @@ <input type="range" min="1" - max="100" - value="10" + max="10" class="slider" id="range" /> @@ -192,18 +191,31 @@ <b>Other colours</b> </div> <label id="colours"> - <input id="other-colours" type="color" value="#0000ff" /> + <input id="other-colours" type="color" /> </label> </div> </div> </div> - <button id="eraser-tool"><i class="fa fa-eraser"></i></button> + <button id="eraser-tool" class="selectable-tool"> + <i class="fa fa-eraser"></i> + </button> + <button id="recognition-mode" class="selectable-tool"> + <i class="fa fa-square"></i> + </button> + <div class="spacer"></div> + <div id="status-info"> - <div id="user-avatar"></div> + <button id="fast-undo-tool" class="disabled selectable-tool"> + <i class="fa fa-fast-backward"></i> + </button> + <button id="undo-tool" class="disabled selectable-tool"> + <i class="fa fa-backward"></i> + </button> <div id="connected-room-info"> - Room <i class="fa fa-globe">: </i> + Room: <span id="connected-room-id"></span> </div> + <div id="user-avatar"></div> </div> </div> </div> diff --git a/public/styles.css b/public/styles.css index d1f4a1381ed965be737d4eb3082e5c50a941ad2f..b4ba7fb7579943df3066b77fc788672ac35ef29f 100644 --- a/public/styles.css +++ b/public/styles.css @@ -27,20 +27,18 @@ body { align-items: center; } +.spacer { + flex: 1; +} + #connected-room-info { color: white; display: flex; align-items: center; background-color: #4f4f4fb7; border-radius: 4px; - margin-left: 8px; justify-content: center; - height: 44px; -} - -#connected-room-info:hover { - background-color: #4f4f4f !important; - transition-duration: 0.4s; + padding: 0.75em; } button.selected { @@ -269,7 +267,7 @@ button.selected { border-radius: 4px; } -#pen-tool { +.selectable-tool { background-color: #2f2f2f; color: white; padding: 10px; @@ -277,27 +275,47 @@ button.selected { border: none; cursor: pointer; border-radius: 50%; - margin-right: 8px; } -#pen-tool:hover { +.selectable-tool:hover { background-color: #4f4f4f !important; transition-duration: 0.4s; } +#pen-tool > i { + padding: 0 1px; +} + #eraser-tool { - background-color: #2f2f2f; - color: white; - padding: 10px; - font-size: 16px; - border: none; - cursor: pointer; - border-radius: 50%; + margin-right: 8px; } -#eraser-tool:hover { - background-color: #4f4f4f !important; - transition-duration: 0.4s; +#eraser-tool > i { + padding: 0 0.5px; +} + +#undo-tool { + margin-right: 8px; +} + +#undo-tool.disabled { + display: none; +} + +#undo-tool > i { + padding: 0 3px 0 0; +} + +#fast-undo-tool { + margin-right: 8px; +} + +#fast-undo-tool.disabled { + display: none; +} + +#fast-undo-tool > i { + padding: 0 1px; } .properties { @@ -547,16 +565,12 @@ button.selected { align-items: center; background-color: #4f4f4fb7; border-radius: 4px; -} - -#user-avatar:hover { - background-color: #4f4f4f !important; - transition-duration: 0.4s; + margin-left: 0.75em; + padding: 0 0.75em 0 0; } #status-info { display: flex; align-items: center; justify-content: right; - width: 100%; } diff --git a/src/app.js b/src/app.js index 57f9c36c3389c68657532106b4f80928a9a8be7b..4a2f2f811f9a03b69df58b4248f2f7c56978d5a0 100644 --- a/src/app.js +++ b/src/app.js @@ -9,6 +9,7 @@ import { computeErasureIntervals } from "./erasure.js" import { connect } from "./room.js" import WebRTCConnection from "./connection/WebRTC.js" import * as toolSelection from "./tool-selection.js" +import recognizeFromPoints, { Shapes } from "./shapes.js" import * as humanhash from "humanhash" import jdenticon from "jdenticon" @@ -17,6 +18,27 @@ const DEFAULT_ROOM = "imperial" const MIN_PRESSURE_FACTOR = 0.1 const MAX_PRESSURE_FACTOR = 1.5 +const UNDO_RATE = 24 +let undoInterval = null + +let room = null + +const humanHasher = new humanhash() + +const PREDICTED_POINT_COLOR = "#00000044" +const LAST_RECOGNIZED_PATH_ID = "LSP" + +const pathIDsByPointerID = new Map() + +// Safari reports mouse touches as events with 0 pressure, but the standard requires +// it to be 0.5 +function getNormalizedPressure(e) { + if (e.pressure === 0 && e.buttons) { + return 0.5 + } + return e.pressure +} + // This is a quadratic such that: // - getPressureFactor(0.0) = MIN_PRESSURE_FACTOR // - getPressureFactor(0.5) = 1.0 @@ -33,9 +55,22 @@ const getPressureFactor = (pressure) => { return a * pressure ** 2 + b * pressure + c } -let room = null +function selectedRadiusPoint(x, y, color, pressure = 0) { + return [ + x, + y, + toolSelection.getStrokeRadius() * getPressureFactor(pressure), + color, + ] +} -const humanHasher = new humanhash() +function faintPredictionPoint(x, y, pressure = 0.5) { + return selectedRadiusPoint(x, y, PREDICTED_POINT_COLOR, pressure) +} + +function selectedColorAndRadiusPoint(x, y, pressure = 0.5) { + return selectedRadiusPoint(x, y, toolSelection.getStrokeColour(), pressure) +} function eraseEverythingAtPosition(x, y, radius, room) { const mousePos = [x, y] @@ -139,6 +174,67 @@ const onRoomConnect = (room_) => { canvas.renderPath(id, room.getPathPoints(id), intervals) }, ) + + room.addEventListener("undoEnabled", () => { + HTML.fastUndoButton.classList.remove("disabled") + HTML.undoButton.classList.remove("disabled") + }) +} + +function getRecognizedShapePoints(points) { + const recognizedShape = recognizeFromPoints(points) + if (!recognizedShape.shape) return undefined + + switch (recognizedShape.shape) { + case Shapes.line: { + const p1 = recognizedShape.firstPoint + const p2 = recognizedShape.lastPoint + return [p1, p2] + } + case Shapes.rectangle: { + return recognizedShape.boundingPoints + } + } + + return undefined +} + +function drawIfRecognized(points, callback, notRecCallback) { + const recognizedPoints = getRecognizedShapePoints(points) + if (recognizedPoints) { + callback && callback(recognizedPoints) + } else { + notRecCallback && notRecCallback() + } +} + +function clearRecognizedUpcoming() { + canvas.renderPath(LAST_RECOGNIZED_PATH_ID, [], []) +} + +function drawRecognizedUpcoming(points) { + drawIfRecognized( + points, + (recognizedPoints) => + canvas.renderPath( + LAST_RECOGNIZED_PATH_ID, + recognizedPoints.map((point) => + faintPredictionPoint(point[0], point[1]), + ), + [], + ), + clearRecognizedUpcoming, + ) +} + +function drawRecognized(pathID, points) { + drawIfRecognized(points, (newPoints) => + room.replacePath( + pathID, + newPoints.map((point) => selectedColorAndRadiusPoint(point[0], point[1])), + ), + ) + clearRecognizedUpcoming() } const tryRoomConnect = async (roomID) => { @@ -147,8 +243,6 @@ const tryRoomConnect = async (roomID) => { .catch((err) => alert(`Error connecting to a room:\n${err}`)) } -const pathIDsByPointerID = new Map() - HTML.peerButton.addEventListener("click", () => { const peerID = HTML.peerIDElem.value if (room == null || peerID == "") { @@ -171,12 +265,46 @@ const onRoomJoinEnter = () => { canvas.clear() HTML.connectedPeers.innerHTML = "No peers are connected" + HTML.fastUndoButton.classList.add("disabled") + HTML.undoButton.classList.add("disabled") tryRoomConnect(selectedRoomID) } HTML.roomConnectButton.addEventListener("click", onRoomJoinEnter) +HTML.fastUndoButton.addEventListener("click", () => { + if (room == null) return + + room.fastUndo() + + if (!room.canUndo()) { + HTML.fastUndoButton.classList.add("disabled") + HTML.undoButton.classList.add("disabled") + } +}) + +HTML.undoButton.addEventListener("mouseup", () => { + clearInterval(undoInterval) +}) + +HTML.undoButton.addEventListener("mouseleave", () => { + clearInterval(undoInterval) +}) + +HTML.undoButton.addEventListener("mousedown", () => { + undoInterval = setInterval(function() { + if (room == null) return + + room.undo() + + if (!room.canUndo()) { + HTML.fastUndoButton.classList.add("disabled") + HTML.undoButton.classList.add("disabled") + } + }, 1000 / UNDO_RATE) +}) + HTML.roomIDElem.addEventListener("keydown", (event) => { if (event.key == "Enter") { event.target.blur() @@ -244,6 +372,10 @@ const updateOverallStatusIcon = () => { } canvas.input.addEventListener("strokestart", ({ detail: e }) => { + e.preventDefault() + const pressure = getNormalizedPressure(e) + + clearRecognizedUpcoming() if (room == null) { return } @@ -252,45 +384,39 @@ canvas.input.addEventListener("strokestart", ({ detail: e }) => { if (currentTool == toolSelection.Tools.PEN) { pathIDsByPointerID.set( e.pointerId, - room.addPath([ - ...mousePos, - toolSelection.getStrokeRadius() * getPressureFactor(e.pressure), - toolSelection.getStrokeColour(), - ]), + room.addPath(selectedColorAndRadiusPoint(...mousePos, pressure)), ) } else if (currentTool == toolSelection.Tools.ERASER) { - eraseEverythingAtPosition( - mousePos[0], - mousePos[1], - toolSelection.getEraseRadius(), - room, - ) + eraseEverythingAtPosition(...mousePos, toolSelection.getEraseRadius(), room) } }) canvas.input.addEventListener("strokeend", ({ detail: e }) => { - pathIDsByPointerID.delete(e.pointerId) + const { pointerId } = e + const pressure = getNormalizedPressure(e) + const pathID = pathIDsByPointerID.get(pointerId) + if (toolSelection.isRecognitionModeSet()) { + drawRecognized(pathID, room.getPathPoints(pathID), pressure) + } + pathIDsByPointerID.delete(pointerId) + clearRecognizedUpcoming() }) canvas.input.addEventListener("strokemove", ({ detail: e }) => { if (room == null) { return } + const pressure = getNormalizedPressure(e) const currentTool = toolSelection.getTool() const mousePos = [e.offsetX, e.offsetY] if (currentTool == toolSelection.Tools.PEN) { - room.extendPath(pathIDsByPointerID.get(e.pointerId), [ - ...mousePos, - toolSelection.getStrokeRadius() * getPressureFactor(e.pressure), - toolSelection.getStrokeColour(), - ]) + const pathID = pathIDsByPointerID.get(e.pointerId) + room.extendPath(pathID, selectedColorAndRadiusPoint(...mousePos, pressure)) + if (toolSelection.isRecognitionModeSet()) { + drawRecognizedUpcoming(room.getPathPoints(pathID), pressure) + } } else if (currentTool == toolSelection.Tools.ERASER) { - eraseEverythingAtPosition( - mousePos[0], - mousePos[1], - toolSelection.getEraseRadius(), - room, - ) + eraseEverythingAtPosition(...mousePos, toolSelection.getEraseRadius(), room) } }) diff --git a/src/canvas.js b/src/canvas.js index 26c9c758c5892be1a128bc2832f4bb8f081610c4..7e4774c03f251dde9d5109b9c305d4f40604f10b 100644 --- a/src/canvas.js +++ b/src/canvas.js @@ -1,3 +1,5 @@ +import "array-flat-polyfill" + // Local canvas rendering. // Emit input events and receive draw calls seperately - these must be piped // together externally if desired. @@ -237,12 +239,13 @@ export const clear = () => { } // Necessary since buttons property is non standard on iOS versions < 13.2 -const isValidPointerEvent = (e) => { +const isValidPointerEvent = (e, name) => { + if (name === "strokeend") return true return e.buttons & 1 || e.pointerType === "touch" } const dispatchPointerEvent = (name) => (e) => { - if (isValidPointerEvent(e)) { + if (isValidPointerEvent(e, name)) { input.dispatchEvent(new CustomEvent(name, { detail: e })) } } @@ -251,6 +254,8 @@ const dispatchPointerEvent = (name) => (e) => { canvas.addEventListener("pointerdown", dispatchPointerEvent("strokestart")) canvas.addEventListener("pointerenter", dispatchPointerEvent("strokestart")) canvas.addEventListener("pointerup", dispatchPointerEvent("strokeend")) +canvas.addEventListener("onmouseup", dispatchPointerEvent("strokeend")) +canvas.addEventListener("mouseup", dispatchPointerEvent("strokeend")) canvas.addEventListener("pointerleave", dispatchPointerEvent("strokeend")) canvas.addEventListener("pointermove", dispatchPointerEvent("strokemove")) canvas.addEventListener("touchmove", (e) => e.preventDefault()) diff --git a/src/elements.js b/src/elements.js index b1902a21ff6bd350fede2d44e7300c243ab7efa3..a80d58d41cd812e8115f68f9ea6c91bc3073865a 100644 --- a/src/elements.js +++ b/src/elements.js @@ -12,6 +12,10 @@ export const overallStatusIconImage = document.getElementById( export const canvas = document.getElementById("canvas") export const penButton = document.getElementById("pen-tool") export const eraserButton = document.getElementById("eraser-tool") +export const recognitionModeButton = document.getElementById("recognition-mode") + +export const fastUndoButton = document.getElementById("fast-undo-tool") +export const undoButton = document.getElementById("undo-tool") export const roomIDElem = document.getElementById("room-id") export const roomConnectButton = document.getElementById("room-connect") @@ -25,8 +29,8 @@ export const closeButton = document.querySelectorAll(".close") export const palette = document.getElementById("palette") export const rectangle = document.getElementById("rectangle") export const wheel = document.getElementById("wheel") -export const picker = document.getElementById("other-colours") -export const slider = document.getElementById("range") +export const strokeColorPicker = document.getElementById("other-colours") +export const strokeRadiusSlider = document.getElementById("range") export const output = document.getElementById("value") export const labelColours = document.getElementById("colours") export const userInfo = document.getElementById("user-avatar") diff --git a/src/room.js b/src/room.js index 69d11a2697c9b8916efbd2abf9d246cf6b615a56..1f898d753b62d2e497c1cf28f6fb72aa44889d52 100644 --- a/src/room.js +++ b/src/room.js @@ -27,6 +27,7 @@ class Room extends EventTarget { this.name = name this._y = null this.ownID = null + this.undoStack = [] } disconnect() { @@ -39,11 +40,25 @@ class Room extends EventTarget { this.shared.strokePoints.set(id, Y.Array).push([[x, y, w, colour]]) this.shared.eraseIntervals.set(id, Y.Union) + this.undoStack.push([id, 0, 0]) + + this.dispatchEvent(new CustomEvent("undoEnabled")) + return id } extendPath(id, [x, y, w, colour]) { - this.shared.strokePoints.get(id).push([[x, y, w, colour]]) + const path = this.shared.strokePoints.get(id) + + path.push([[x, y, w, colour]]) + + if (path.length == 2) { + this.undoStack[this.undoStack.length - 1] = [id, 0, 1] + } else { + this.undoStack.push([id, path.length - 2, path.length - 1]) + } + + this.dispatchEvent(new CustomEvent("undoEnabled")) } extendErasureIntervals(pathID, pointID, newIntervals) { @@ -52,6 +67,46 @@ class Room extends EventTarget { .merge(flattenErasureIntervals({ [pointID]: newIntervals })) } + replacePath(pathID, newPoints) { + this.fastUndo(true) + newPoints.forEach((point) => this.extendPath(pathID, point)) + this.undoStack.splice(this.undoStack.length - newPoints.length, 1) + } + + undo() { + const operation = this.undoStack.pop() + + if (!operation) return + + const [id, ...interval] = operation + + this.shared.eraseIntervals.get(id).merge([interval]) + } + + fastUndo(forReplacing = false) { + let from = this.undoStack.length - 1 + + if (from < 0) return + + // eslint-disable-next-line no-unused-vars + const [id, _, end] = this.undoStack[from] + const endErasing = forReplacing ? end + 1 : end + + for (; from >= 0; from--) { + if (this.undoStack[from][0] != id) { + from++ + break + } + } + + this.undoStack = this.undoStack.slice(0, Math.max(0, from)) + this.shared.eraseIntervals.get(id).merge([[0, endErasing]]) + } + + canUndo() { + return this.undoStack.length > 0 + } + getPaths() { const paths = new Map() diff --git a/src/shapes.js b/src/shapes.js new file mode 100644 index 0000000000000000000000000000000000000000..213b02be1da496e96afbaed22131e2f802236130 --- /dev/null +++ b/src/shapes.js @@ -0,0 +1,191 @@ +const LINE_ANGLE_THRESHOLD = Math.PI / 6 +const VECTOR_LEN_THRESHOLD_FRACTION = 0.3 + +const RECT_MATRIX_SIZE = 3 +const RECT_MATRIX_CENTER_RATIO = 0.65 + +const RECT_THRESHOLD_CENTER = 0 +const RECT_THRESHOLD_SIDE_VARIANCE = 0.25 + +const MIN_RECT_POINTS = 4 +const MIN_LINE_POINTS = 2 + +function getDistance(p1, p2) { + if (!(p1 && p2)) return 0 + const [[x0, y0], [x1, y1]] = [p1, p2] + return Math.hypot(x1 - x0, y1 - y0) +} + +function vectorLength([x, y]) { + return Math.hypot(x, y) +} + +function diffVector([x0, y0], [x1, y1]) { + return [x0 - x1, y0 - y1] +} + +function angleBetweenVectors(p1, p2) { + const [[x0, y0], [x1, y1]] = [p1, p2] + return Math.acos((x0 * x1 + y0 * y1) / (vectorLength(p1) * vectorLength(p2))) +} + +function boundingCoords(points) { + const xs = points.map((p) => p[0]) + const ys = points.map((p) => p[1]) + return { + maxX: Math.max(...xs), + minX: Math.min(...xs), + maxY: Math.max(...ys), + minY: Math.min(...ys), + } +} + +function matrixBoundsArray(min, max) { + const d = max - min + const centerSegmentSize = d * RECT_MATRIX_CENTER_RATIO + const smallStep = (d - centerSegmentSize) / 2 + const p = [min + smallStep, min + smallStep + centerSegmentSize, max] + return p +} + +function getCluster([x, y], xBounds, yBounds) { + return { + x: xBounds.findIndex((bound) => x <= bound), + y: yBounds.findIndex((bound) => y <= bound), + } +} + +function computeClusters(points, xBounds, yBounds) { + const clusters = Array(RECT_MATRIX_SIZE) + .fill(0) + .map(() => + Array(RECT_MATRIX_SIZE) + .fill() + .map(() => ({ arr: [], sum: 0 })), + ) + const intervals = points.map((point, i) => ({ + point, + dist: getDistance(point, points[i + 1]), + })) + + let totalSum = 0 + intervals.forEach((interval) => { + const { x, y } = getCluster(interval.point, xBounds, yBounds) + clusters[x][y].arr.push(interval) + clusters[x][y].sum += interval.dist + totalSum += interval.dist + }) + + return { arr: clusters, totalSum } +} + +function clusterCoefficients(clusters) { + return clusters.arr.map((rowCluster) => + rowCluster.map((cluster) => cluster.sum / clusters.totalSum), + ) +} + +export function computeMatrixCoefficients(points, boundingRect) { + const { maxX, minX, maxY, minY } = boundingRect + const xBounds = matrixBoundsArray(minX, maxX) + const yBounds = matrixBoundsArray(minY, maxY) + const clusters = computeClusters(points, xBounds, yBounds) + const coefficients = clusterCoefficients(clusters, points) + return coefficients +} + +function couldBeRect(points) { + if (points.length < MIN_RECT_POINTS) return false + + const boundingRect = boundingCoords(points) + const matrixCoefficients = computeMatrixCoefficients(points, boundingRect) + + let [maxC, minC] = [0, 1] + for (let i = 0; i < RECT_MATRIX_SIZE; i++) { + for (let j = 0; j < RECT_MATRIX_SIZE; j++) { + if (!(i === j && j === 1)) { + maxC = Math.max(maxC, matrixCoefficients[i][j]) + minC = Math.min(minC, matrixCoefficients[i][j]) + } + } + } + + if ( + matrixCoefficients[1][1] <= RECT_THRESHOLD_CENTER && + maxC - minC < RECT_THRESHOLD_SIDE_VARIANCE + ) { + return { coefficients: matrixCoefficients, boundingRect } + } + return undefined +} + +function couldBeLine(points) { + if (points.length < MIN_LINE_POINTS) return false + + const vectorThreshold = Math.floor( + points.length * VECTOR_LEN_THRESHOLD_FRACTION, + ) + const pivot = points[0] + let cumulativeThreshold = 0 + + for (let i = 2; i < points.length; i++) { + const prev = points[i - 1] + const curr = points[i] + const d1 = diffVector(pivot, prev) + const d2 = diffVector(prev, curr) + const angle = angleBetweenVectors(d1, d2) + + if (Math.abs(angle) > LINE_ANGLE_THRESHOLD) { + const d2Len = vectorLength(d2) + if (cumulativeThreshold < vectorThreshold && d2Len < vectorThreshold) { + cumulativeThreshold += d2Len + continue + } + return false + } + } + return true +} + +function recognizedRect(_, rectDetectionData) { + const { minX, minY, maxX, maxY } = rectDetectionData.boundingRect + return { + boundingPoints: [ + [minX, minY], + [minX, maxY], + [maxX, maxY], + [maxX, minY], + [minX, minY], + ], + shape: Shapes.rectangle, + } +} + +function recognizedLine(points) { + const [p1, p2] = [points[0], points[points.length - 1]] + return { + shape: Shapes.line, + + // Take only [x, y] from the whole point tuple + lastPoint: p2.slice(0, 2), + firstPoint: p1.slice(0, 2), + } +} + +function recognizeFromPoints(points) { + const rectDetectData = couldBeRect(points) + if (rectDetectData) { + return recognizedRect(points, rectDetectData) + } else if (couldBeLine(points)) { + return recognizedLine(points) + } + + return {} +} + +export const Shapes = { + rectangle: "rect", + line: "line", +} + +export default recognizeFromPoints diff --git a/src/tool-selection.js b/src/tool-selection.js index 82bef482629aede3da532676bb0d5da91d9a607b..e8b0e8c1f27b23ac983191b3670b3009a24bb67c 100644 --- a/src/tool-selection.js +++ b/src/tool-selection.js @@ -5,16 +5,19 @@ export const Tools = Object.freeze({ ERASER: Symbol("eraser"), }) -let tool = Tools.PEN -let strokeColour = "#0000ff" -let strokeRadius = 5 +let selectedTool = Tools.PEN +let strokeColour = "#000000" +let strokeRadius = 2 +let recognitionEnabled = false // TODO: The erase radius should also be selectable. const ERASE_RADIUS = 20 -export const getTool = () => tool +export const getTool = () => selectedTool export const getStrokeColour = () => strokeColour export const getStrokeRadius = () => strokeRadius + export const getEraseRadius = () => ERASE_RADIUS +export const isRecognitionModeSet = () => recognitionEnabled const showElement = (element) => { element.style.display = "block" @@ -24,40 +27,52 @@ const hideElement = (element) => { element.style.display = "none" } +HTML.strokeColorPicker.setAttribute("value", strokeColour) +HTML.strokeRadiusSlider.setAttribute("value", strokeRadius) + +HTML.recognitionModeButton.addEventListener("click", () => { + recognitionEnabled = !recognitionEnabled + if (recognitionEnabled) { + HTML.recognitionModeButton.classList.add("selected") + } else { + HTML.recognitionModeButton.classList.remove("selected") + } +}) + HTML.penButton.addEventListener("click", () => { - if (tool == Tools.PEN) { + if (selectedTool == Tools.PEN) { showElement(HTML.penProperties) } else { - tool = Tools.PEN + selectedTool = Tools.PEN HTML.penButton.classList.add("selected") HTML.eraserButton.classList.remove("selected") } }) HTML.eraserButton.addEventListener("click", () => { - tool = Tools.ERASER + selectedTool = Tools.ERASER HTML.penButton.classList.remove("selected") HTML.eraserButton.classList.add("selected") }) -HTML.picker.addEventListener("change", () => { +HTML.strokeColorPicker.addEventListener("change", () => { const paletteColour = event.target.value HTML.rectangle.style.backgroundColor = paletteColour HTML.labelColours.style.backgroundColor = paletteColour strokeColour = paletteColour }) -HTML.slider.oninput = function() { +HTML.strokeRadiusSlider.oninput = function() { HTML.output.innerHTML = this.value strokeRadius = this.value / 2 } -HTML.output.innerHTML = HTML.slider.value +HTML.output.innerHTML = HTML.strokeRadiusSlider.value // If the page has been refreshed if (performance.navigation.type == 1) { const sliderValue = parseInt(HTML.output.innerHTML) - HTML.slider.setAttribute("value", sliderValue) + HTML.strokeRadiusSlider.setAttribute("value", sliderValue) strokeRadius = sliderValue / 2 } @@ -97,7 +112,7 @@ for (let i = 1; i < svg.length; i++) { svg[i].addEventListener("click", (event) => { const paletteColour = event.target.getAttribute("fill") HTML.rectangle.style.backgroundColor = paletteColour - HTML.picker.value = paletteColour + HTML.strokeColorPicker.value = paletteColour HTML.labelColours.style.backgroundColor = paletteColour strokeColour = paletteColour hideElement(HTML.palette)