@lvl99dotcom/paintcanvas 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/README.md +1 -1
  2. package/dist/lib/brushes/StrokeEngine/index.d.ts +2 -2
  3. package/dist/lib/brushes/StrokeEngine/index.js +26 -28
  4. package/dist/lib/classes/BrushSettings/index.d.ts +24 -0
  5. package/dist/lib/classes/BrushSettings/index.js +35 -1
  6. package/dist/lib/classes/KeyboardShortcuts/index.d.ts +17 -6
  7. package/dist/lib/classes/KeyboardShortcuts/index.js +81 -18
  8. package/dist/lib/classes/PaintCanvas/index.d.ts +77 -27
  9. package/dist/lib/classes/PaintCanvas/index.js +143 -42
  10. package/dist/lib/classes/PaintCanvas/tool.d.ts +15 -0
  11. package/dist/lib/classes/PaintCanvas/tool.js +18 -0
  12. package/dist/lib/classes/Palette/index.d.ts +58 -0
  13. package/dist/lib/classes/Palette/index.js +92 -0
  14. package/dist/lib/classes/SettingsAutosave/index.d.ts +52 -0
  15. package/dist/lib/classes/SettingsAutosave/index.js +96 -0
  16. package/dist/lib/classes/ShortcutSettings/index.d.ts +63 -0
  17. package/dist/lib/classes/ShortcutSettings/index.js +110 -0
  18. package/dist/lib/palette/index.d.ts +49 -0
  19. package/dist/lib/palette/index.js +82 -0
  20. package/dist/lib/palette/schema.d.ts +45 -0
  21. package/dist/lib/palette/schema.js +41 -0
  22. package/dist/lib/rendering/cursors/index.d.ts +2 -0
  23. package/dist/lib/rendering/cursors/index.js +3 -1
  24. package/dist/lib/rendering/sample/index.js +3 -2
  25. package/dist/lib/settings/index.d.ts +63 -0
  26. package/dist/lib/settings/index.js +91 -0
  27. package/dist/lib/settings/schema.d.ts +150 -0
  28. package/dist/lib/settings/schema.js +102 -0
  29. package/dist/lib/storage/index.d.ts +36 -5
  30. package/dist/lib/storage/index.js +87 -29
  31. package/dist/lib/utils/colour/index.d.ts +72 -0
  32. package/dist/lib/utils/colour/index.js +154 -0
  33. package/dist/lib/utils/shortcuts/index.d.ts +174 -11
  34. package/dist/lib/utils/shortcuts/index.js +391 -145
  35. package/dist/lib/validation/validators.generated.d.ts +5 -0
  36. package/dist/lib/validation/validators.generated.js +872 -1
  37. package/dist/locales/en-GB.js +87 -1
  38. package/dist/locales/en-US.js +87 -1
  39. package/dist/locales/fr.js +87 -1
  40. package/dist/locales/ja.js +87 -1
  41. package/dist/locales/ko.js +87 -1
  42. package/dist/locales/zh-Hans.js +87 -1
  43. package/dist/locales/zh-Hant.js +87 -1
  44. package/dist/package.js +1 -1
  45. package/dist/ui/elements/PaintCanvas/index.d.ts +49 -47
  46. package/dist/ui/elements/PaintCanvas/index.js +128 -169
  47. package/dist/ui/elements/PaintCanvas/style.js +1 -1
  48. package/dist/ui/elements/PcAppSettings/index.d.ts +59 -0
  49. package/dist/ui/elements/PcAppSettings/index.js +222 -0
  50. package/dist/ui/elements/PcBrushConfig/index.js +1 -0
  51. package/dist/ui/elements/PcButton/index.js +1 -0
  52. package/dist/ui/elements/PcCanvas/index.d.ts +1 -1
  53. package/dist/ui/elements/PcCanvas/index.js +7 -5
  54. package/dist/ui/elements/PcColourPair/index.d.ts +43 -0
  55. package/dist/ui/elements/PcColourPair/index.js +125 -0
  56. package/dist/ui/elements/PcContextMenu/index.d.ts +70 -33
  57. package/dist/ui/elements/PcContextMenu/index.js +128 -97
  58. package/dist/ui/elements/PcDialog/index.js +1 -0
  59. package/dist/ui/elements/PcIconToggle/index.js +1 -0
  60. package/dist/ui/elements/PcLayerItem/index.d.ts +13 -34
  61. package/dist/ui/elements/PcLayerItem/index.js +39 -91
  62. package/dist/ui/elements/PcLayerItem/style.js +1 -1
  63. package/dist/ui/elements/PcMenu/index.d.ts +93 -0
  64. package/dist/ui/elements/PcMenu/index.js +204 -0
  65. package/dist/ui/elements/PcPane/index.d.ts +118 -0
  66. package/dist/ui/elements/PcPane/index.js +357 -0
  67. package/dist/ui/elements/PcPanel/index.d.ts +26 -8
  68. package/dist/ui/elements/PcPanel/index.js +76 -8
  69. package/dist/ui/elements/{PcBrushMenu → PcPanelBrushes}/index.d.ts +20 -6
  70. package/dist/ui/elements/{PcBrushMenu → PcPanelBrushes}/index.js +20 -15
  71. package/dist/ui/elements/PcPanelBrushes/style.js +3 -0
  72. package/dist/ui/elements/PcPanelColours/index.d.ts +171 -0
  73. package/dist/ui/elements/PcPanelColours/index.js +650 -0
  74. package/dist/ui/elements/PcPanelColours/style.js +3 -0
  75. package/dist/ui/elements/{PcLayersMenu → PcPanelLayers}/index.d.ts +17 -4
  76. package/dist/ui/elements/{PcLayersMenu → PcPanelLayers}/index.js +17 -10
  77. package/dist/ui/elements/PcPanelLayers/style.js +3 -0
  78. package/dist/ui/elements/PcShortcutSettings/index.d.ts +69 -0
  79. package/dist/ui/elements/PcShortcutSettings/index.js +273 -0
  80. package/dist/ui/elements/PcThemePicker/index.js +1 -0
  81. package/dist/ui/elements/PcToast/index.js +1 -0
  82. package/dist/ui/elements/PcToolSettings/index.js +2 -1
  83. package/dist/ui/elements/PcToolbar/index.d.ts +47 -12
  84. package/dist/ui/elements/PcToolbar/index.js +77 -56
  85. package/dist/ui/elements/PcToolbar/style.js +1 -1
  86. package/dist/ui/index.d.ts +7 -1
  87. package/dist/ui/index.js +8 -2
  88. package/dist/ui/tokens/index.js +3 -1
  89. package/locales/en-GB.json +87 -1
  90. package/locales/en-US.json +87 -1
  91. package/locales/fr.json +87 -1
  92. package/locales/ja.json +87 -1
  93. package/locales/ko.json +87 -1
  94. package/locales/zh-Hans.json +87 -1
  95. package/locales/zh-Hant.json +87 -1
  96. package/package.json +1 -1
  97. package/dist/ui/elements/PcBrushMenu/style.js +0 -3
  98. package/dist/ui/elements/PcLayersMenu/style.js +0 -3
package/README.md CHANGED
@@ -87,7 +87,7 @@ pnpm dev
87
87
 
88
88
  Starts a development server and opens the demo in your browser. It uses the source directly, so saving a change to `src/`, `demo/` or `locales/` reloads the page with the change straight away. (The whole page reloads because custom elements can't be redefined in a running page, so the drawing starts again.)
89
89
 
90
- - http://localhost:5190/: a full-page PaintCanvas with a layers panel, a menu with input settings, a dark theme switch and a "New drawing" button. The drawing is kept in the browser, so it survives reloads.
90
+ - http://localhost:5190/: a full-page PaintCanvas with a layers panel, the colour palette (from the colours at the top of the toolbar), a menu with input settings, keyboard shortcuts, settings import and export, a dark theme switch and a "New drawing" button. The drawing and the settings are kept in the browser, so they survive reloads.
91
91
  - http://localhost:5190/elements.html: the base elements.
92
92
 
93
93
  To try it with a pen or touch on another device (e.g. a tablet) on the same network, run `pnpm dev --host` and open the network address it prints. This makes the server reachable by anyone on that network.
@@ -19,7 +19,7 @@ export interface StrokeOptions {
19
19
  width: number;
20
20
  height: number;
21
21
  };
22
- /** Paint colour as a hex string */
22
+ /** Paint colour as a hex string (for the eraser, the colour it paints when the layer's alpha lock is on) */
23
23
  colour: string;
24
24
  tip: BrushTip;
25
25
  /** Whether input comes from a pen (enables pressure and tilt) */
@@ -76,7 +76,7 @@ export interface StrokeCommitDetail {
76
76
  * Stamps are drawn at full strength into a stroke buffer, so overlapping stamps within one stroke do not build up opacity.
77
77
  * The buffer is merged into the layer with the stroke's opacity (the preset's `alpha`):
78
78
  * - Brush: painted over the layer (or only onto existing pixels when the layer's alpha lock is on).
79
- * - Eraser: removes the buffer's shape from the layer (does nothing when alpha lock is on).
79
+ * - Eraser: removes the buffer's shape from the layer (or paints its colour onto existing pixels when alpha lock is on).
80
80
  * - With a mask, the buffer is first cut to the mask, so the stroke only applies inside it.
81
81
  *
82
82
  * While drawing, `preview` provides the layer with the stroke merged in, to draw instead of the layer. The stroke is written into the layer when it ends.
@@ -39,10 +39,13 @@ var clipRect = (rect, width, height) => {
39
39
  height: bottom - y
40
40
  } : void 0;
41
41
  };
42
- /** How the stroke buffer is merged into the layer, or null if the stroke cannot change it */
42
+ /**
43
+ * How the stroke buffer is merged into the layer: with alpha lock on, both brush and eraser paint only onto existing
44
+ * pixels; otherwise the brush paints over the layer and the eraser removes from it.
45
+ */
43
46
  var compositeOperation = (mode, alphaLock) => {
44
- if (mode === "erase") return alphaLock ? null : "destination-out";
45
- return alphaLock ? "source-atop" : "source-over";
47
+ if (alphaLock) return "source-atop";
48
+ return mode === "erase" ? "destination-out" : "source-over";
46
49
  };
47
50
  /** Event dispatched just before a finished stroke is written into its layer */
48
51
  var BEFORE_COMMIT_EVENT = "beforecommit";
@@ -56,7 +59,7 @@ var STROKE_END_EVENT = "strokeend";
56
59
  * Stamps are drawn at full strength into a stroke buffer, so overlapping stamps within one stroke do not build up opacity.
57
60
  * The buffer is merged into the layer with the stroke's opacity (the preset's `alpha`):
58
61
  * - Brush: painted over the layer (or only onto existing pixels when the layer's alpha lock is on).
59
- * - Eraser: removes the buffer's shape from the layer (does nothing when alpha lock is on).
62
+ * - Eraser: removes the buffer's shape from the layer (or paints its colour onto existing pixels when alpha lock is on).
60
63
  * - With a mask, the buffer is first cut to the mask, so the stroke only applies inside it.
61
64
  *
62
65
  * While drawing, `preview` provides the layer with the stroke merged in, to draw instead of the layer. The stroke is written into the layer when it ends.
@@ -215,7 +218,7 @@ var StrokeEngine = class extends EventTarget {
215
218
  const active = this.active;
216
219
  if (points.length === 0) return;
217
220
  if (this.debugEnabled) this.debugTrace.stamps.push(...points);
218
- const { preset, size, tip, colour, mode, isPen } = active.options;
221
+ const { preset, size, tip, colour, isPen } = active.options;
219
222
  const { random } = active;
220
223
  const ctx = this.buffer.getContext("2d");
221
224
  const stampPreset = {
@@ -230,7 +233,7 @@ var StrokeEngine = class extends EventTarget {
230
233
  y: point.y,
231
234
  params: varied
232
235
  };
233
- tip.stamp(ctx, x, y, params, mode === "erase" ? "#000000" : colour);
236
+ tip.stamp(ctx, x, y, params, colour);
234
237
  const reach = Math.hypot(params.width, params.height) / 2 + STAMP_MARGIN;
235
238
  changed = unionRect(changed, {
236
239
  x: x + params.offsetX - reach,
@@ -258,12 +261,9 @@ var StrokeEngine = class extends EventTarget {
258
261
  ctx.clearRect(x, y, width, height);
259
262
  ctx.drawImage(layer.canvas, x, y, width, height, x, y, width, height);
260
263
  }
261
- const operation = compositeOperation(mode, layer.alphaLock);
262
- if (operation) {
263
- ctx.globalAlpha = Math.max(0, Math.min(1, preset.alpha ?? 1));
264
- ctx.globalCompositeOperation = operation;
265
- ctx.drawImage(this.strokeImage(active, area), x, y, width, height, x, y, width, height);
266
- }
264
+ ctx.globalAlpha = Math.max(0, Math.min(1, preset.alpha ?? 1));
265
+ ctx.globalCompositeOperation = compositeOperation(mode, layer.alphaLock);
266
+ ctx.drawImage(this.strokeImage(active, area), x, y, width, height, x, y, width, height);
267
267
  ctx.restore();
268
268
  }
269
269
  /**
@@ -289,22 +289,20 @@ var StrokeEngine = class extends EventTarget {
289
289
  commit(active) {
290
290
  const { layer, mode, preset, size, colour, isPen } = active.options;
291
291
  if (active.dirty) {
292
- if (compositeOperation(mode, layer.alphaLock)) {
293
- const stroke = {
294
- mode,
295
- preset,
296
- size,
297
- colour,
298
- isPen,
299
- seed: active.seed,
300
- points: active.points
301
- };
302
- this.dispatchEvent(new CustomEvent(BEFORE_COMMIT_EVENT, { detail: {
303
- layer,
304
- area: active.dirty,
305
- stroke
306
- } }));
307
- }
292
+ const stroke = {
293
+ mode,
294
+ preset,
295
+ size,
296
+ colour,
297
+ isPen,
298
+ seed: active.seed,
299
+ points: active.points
300
+ };
301
+ this.dispatchEvent(new CustomEvent(BEFORE_COMMIT_EVENT, { detail: {
302
+ layer,
303
+ area: active.dirty,
304
+ stroke
305
+ } }));
308
306
  this.mergeInto(active.options.layer.canvas, active, active.dirty, false);
309
307
  this.clearBuffer(active.dirty);
310
308
  this.changedArea = unionRect(this.changedArea, active.dirty);
@@ -5,6 +5,10 @@ export declare const MIN_BRUSH_SIZE = 1;
5
5
  export declare const MAX_BRUSH_SIZE = 256;
6
6
  /** Fraction of the current size added or removed by `grow` and `shrink` ("]" and "[" keys) */
7
7
  export declare const BRUSH_SIZE_STEP = 0.1;
8
+ /** Opacity step for the opacity keyboard shortcuts, in percent */
9
+ export declare const BRUSH_OPACITY_STEP = 10;
10
+ /** Lowest opacity the opacity keyboard shortcuts go to, in percent */
11
+ export declare const MIN_BRUSH_OPACITY = 1;
8
12
  /**
9
13
  * Clamp a value between min and max.
10
14
  *
@@ -12,6 +16,16 @@ export declare const BRUSH_SIZE_STEP = 0.1;
12
16
  * clamp(300, 1, 256); // 256
13
17
  */
14
18
  export declare const clamp: (value: number, min: number, max: number) => number;
19
+ /**
20
+ * The next opacity step up or down, in percent: to the next multiple of `BRUSH_OPACITY_STEP`, from
21
+ * `MIN_BRUSH_OPACITY` to 100.
22
+ *
23
+ * @example
24
+ * stepOpacity(45, 1); // 50
25
+ * stepOpacity(10, -1); // 1
26
+ * stepOpacity(1, 1); // 10
27
+ */
28
+ export declare const stepOpacity: (percent: number, direction: 1 | -1) => number;
15
29
  /**
16
30
  * The settings of a brush or eraser: the selected preset, an editable copy of its settings (changed from the tool
17
31
  * settings bar or the configuration window, without changing the preset) and the size. The brush and eraser each have
@@ -80,6 +94,16 @@ export default class BrushSettings extends EventTarget {
80
94
  * Decrease the size by one step (at least 1 pixel).
81
95
  */
82
96
  shrink(): void;
97
+ /**
98
+ * Increase the opacity by one step (see `stepOpacity`).
99
+ */
100
+ moreOpaque(): void;
101
+ /**
102
+ * Decrease the opacity by one step (see `stepOpacity`).
103
+ */
104
+ lessOpaque(): void;
105
+ /** Change the opacity by one step up or down */
106
+ private stepOpacity;
83
107
  /**
84
108
  * Use a preset's (or configuration's) default size.
85
109
  */
@@ -5,6 +5,10 @@ var MIN_BRUSH_SIZE = 1;
5
5
  var MAX_BRUSH_SIZE = 256;
6
6
  /** Fraction of the current size added or removed by `grow` and `shrink` ("]" and "[" keys) */
7
7
  var BRUSH_SIZE_STEP = .1;
8
+ /** Opacity step for the opacity keyboard shortcuts, in percent */
9
+ var BRUSH_OPACITY_STEP = 10;
10
+ /** Lowest opacity the opacity keyboard shortcuts go to, in percent */
11
+ var MIN_BRUSH_OPACITY = 1;
8
12
  /**
9
13
  * Clamp a value between min and max.
10
14
  *
@@ -13,6 +17,19 @@ var BRUSH_SIZE_STEP = .1;
13
17
  */
14
18
  var clamp = (value, min, max) => Math.max(min, Math.min(max, value));
15
19
  /**
20
+ * The next opacity step up or down, in percent: to the next multiple of `BRUSH_OPACITY_STEP`, from
21
+ * `MIN_BRUSH_OPACITY` to 100.
22
+ *
23
+ * @example
24
+ * stepOpacity(45, 1); // 50
25
+ * stepOpacity(10, -1); // 1
26
+ * stepOpacity(1, 1); // 10
27
+ */
28
+ var stepOpacity = (percent, direction) => {
29
+ const steps = percent / 10;
30
+ return clamp((direction > 0 ? Math.floor(steps + 1e-9) + 1 : Math.ceil(steps - 1e-9) - 1) * 10, 1, 100);
31
+ };
32
+ /**
16
33
  * The settings of a brush or eraser: the selected preset, an editable copy of its settings (changed from the tool
17
34
  * settings bar or the configuration window, without changing the preset) and the size. The brush and eraser each have
18
35
  * their own instance.
@@ -136,6 +153,23 @@ var BrushSettings = class extends EventTarget {
136
153
  this.resize(-Math.max(1, Math.round(this.width * BRUSH_SIZE_STEP)));
137
154
  }
138
155
  /**
156
+ * Increase the opacity by one step (see `stepOpacity`).
157
+ */
158
+ moreOpaque() {
159
+ this.stepOpacity(1);
160
+ }
161
+ /**
162
+ * Decrease the opacity by one step (see `stepOpacity`).
163
+ */
164
+ lessOpaque() {
165
+ this.stepOpacity(-1);
166
+ }
167
+ /** Change the opacity by one step up or down */
168
+ stepOpacity(direction) {
169
+ const percent = Math.round((this.current.alpha ?? 1) * 100);
170
+ this.update({ alpha: stepOpacity(percent, direction) / 100 });
171
+ }
172
+ /**
139
173
  * Use a preset's (or configuration's) default size.
140
174
  */
141
175
  applyPresetSize(preset) {
@@ -149,4 +183,4 @@ var BrushSettings = class extends EventTarget {
149
183
  this.dispatchEvent(new Event("change"));
150
184
  }
151
185
  };
152
- export { BRUSH_SIZE_STEP, MAX_BRUSH_SIZE, MIN_BRUSH_SIZE, clamp, BrushSettings as default };
186
+ export { BRUSH_OPACITY_STEP, BRUSH_SIZE_STEP, MAX_BRUSH_SIZE, MIN_BRUSH_OPACITY, MIN_BRUSH_SIZE, clamp, BrushSettings as default, stepOpacity };
@@ -3,12 +3,15 @@ import { type ShortcutAction } from "../../utils/shortcuts";
3
3
  /** Event dispatched on the window for shortcut actions handled elsewhere (e.g. saving) */
4
4
  export declare const SHORTCUT_EVENT = "pc-shortcut";
5
5
  /**
6
- * Listens for key presses and runs the matching shortcut on the app. Shortcuts are ignored while typing in text fields.
6
+ * Listens for key presses and runs the matching shortcut on the app, using the user's key combinations
7
+ * (`app.shortcuts`). Shortcuts are ignored while typing in text fields and inside open `<dialog>` elements. Key presses are matched on keyboards with
8
+ * different layouts (see `comboFromEvent`).
7
9
  *
8
- * Tool selection, brush/eraser size, interface visibility, select all/deselect, undo/redo, cut/copy/paste, deleting the
9
- * masked image data (Delete or Backspace) and finishing or cancelling a
10
- * lasso shape are handled here; other actions (and Escape/Enter for later features) dispatch an `pc-shortcut` event with the action as `detail`.
11
- * Holding Space pans temporarily; releasing it returns to the selected tool.
10
+ * Tool selection, brush/eraser size and opacity, interface visibility, select all/invert/deselect, undo/redo,
11
+ * cut/copy/paste, deleting the masked image data (Delete or Backspace), fills, swapping colours, zooming, layer opacity
12
+ * and finishing or cancelling a lasso shape are handled here; other actions (and Escape/Enter for later features)
13
+ * dispatch an `pc-shortcut` event with the action as `detail`. Holding the hold-to-pan key (Space by default) pans
14
+ * temporarily; releasing it returns to the selected tool.
12
15
  *
13
16
  * @example
14
17
  * const shortcuts = new KeyboardShortcuts(app);
@@ -17,6 +20,8 @@ export declare const SHORTCUT_EVENT = "pc-shortcut";
17
20
  export default class KeyboardShortcuts {
18
21
  private readonly app;
19
22
  private target;
23
+ /** The hold-to-pan key while it is held */
24
+ private heldKey?;
20
25
  private readonly onKeyDown;
21
26
  private readonly onKeyUp;
22
27
  private readonly onBlur;
@@ -34,13 +39,19 @@ export default class KeyboardShortcuts {
34
39
  */
35
40
  handle(event: KeyboardEvent): ShortcutAction | undefined;
36
41
  /**
37
- * Return to the selected tool when the hold-to-pan key is released.
42
+ * Return to the selected tool when the hold-to-pan key is released (whatever modifiers are held by then).
38
43
  */
39
44
  handleKeyUp(event: KeyboardEvent): void;
45
+ /** Stop holding the hold-to-pan key */
46
+ private release;
40
47
  /**
41
48
  * Run a shortcut action.
42
49
  */
43
50
  run(action: ShortcutAction): void;
51
+ /** The brush or eraser settings while that tool is selected */
52
+ private brushSettings;
53
+ /** The middle of the viewport, which zoom shortcuts zoom around */
54
+ private viewportCentre;
44
55
  /**
45
56
  * Dispatch an action as an `pc-shortcut` event for features handled elsewhere.
46
57
  */
@@ -1,15 +1,24 @@
1
- import { Tool } from "../PaintCanvas/index.js";
2
- import { isTypingTarget, matchShortcut } from "../../utils/shortcuts/index.js";
1
+ import { Tool } from "../PaintCanvas/tool.js";
2
+ import "../PaintCanvas/index.js";
3
+ import { comboFromEvent, isTypingTarget, sameCombo } from "../../utils/shortcuts/index.js";
3
4
  // src/lib/classes/KeyboardShortcuts/index.ts
5
+ /**
6
+ * Whether a key press happened inside an open `<dialog>` (e.g. a settings window), where shortcuts must not apply and
7
+ * Escape is left to close it.
8
+ */
9
+ var inOpenDialog = (path) => path.some((target) => target instanceof HTMLDialogElement && target.open);
4
10
  /** Event dispatched on the window for shortcut actions handled elsewhere (e.g. saving) */
5
11
  var SHORTCUT_EVENT = "pc-shortcut";
6
12
  /**
7
- * Listens for key presses and runs the matching shortcut on the app. Shortcuts are ignored while typing in text fields.
13
+ * Listens for key presses and runs the matching shortcut on the app, using the user's key combinations
14
+ * (`app.shortcuts`). Shortcuts are ignored while typing in text fields and inside open `<dialog>` elements. Key presses are matched on keyboards with
15
+ * different layouts (see `comboFromEvent`).
8
16
  *
9
- * Tool selection, brush/eraser size, interface visibility, select all/deselect, undo/redo, cut/copy/paste, deleting the
10
- * masked image data (Delete or Backspace) and finishing or cancelling a
11
- * lasso shape are handled here; other actions (and Escape/Enter for later features) dispatch an `pc-shortcut` event with the action as `detail`.
12
- * Holding Space pans temporarily; releasing it returns to the selected tool.
17
+ * Tool selection, brush/eraser size and opacity, interface visibility, select all/invert/deselect, undo/redo,
18
+ * cut/copy/paste, deleting the masked image data (Delete or Backspace), fills, swapping colours, zooming, layer opacity
19
+ * and finishing or cancelling a lasso shape are handled here; other actions (and Escape/Enter for later features)
20
+ * dispatch an `pc-shortcut` event with the action as `detail`. Holding the hold-to-pan key (Space by default) pans
21
+ * temporarily; releasing it returns to the selected tool.
13
22
  *
14
23
  * @example
15
24
  * const shortcuts = new KeyboardShortcuts(app);
@@ -18,9 +27,11 @@ var SHORTCUT_EVENT = "pc-shortcut";
18
27
  var KeyboardShortcuts = class {
19
28
  app;
20
29
  target = null;
30
+ /** The hold-to-pan key while it is held */
31
+ heldKey;
21
32
  onKeyDown = (event) => this.handle(event);
22
33
  onKeyUp = (event) => this.handleKeyUp(event);
23
- onBlur = () => this.app.releaseHeldTool();
34
+ onBlur = () => this.release();
24
35
  constructor(app) {
25
36
  this.app = app;
26
37
  }
@@ -47,23 +58,33 @@ var KeyboardShortcuts = class {
47
58
  * Run the shortcut for a key press, if any. Returns the action run.
48
59
  */
49
60
  handle(event) {
50
- if (isTypingTarget(event.composedPath()[0] ?? event.target)) return void 0;
51
- if (event.key === " " && !event.metaKey && !event.ctrlKey && !event.altKey) {
61
+ const path = event.composedPath();
62
+ if (isTypingTarget(path[0] ?? event.target) || inOpenDialog(path)) return void 0;
63
+ const combo = comboFromEvent(event);
64
+ if (!combo) return void 0;
65
+ const { holdToPan, shortcuts } = this.app.shortcuts;
66
+ if (holdToPan && sameCombo(combo, holdToPan)) {
52
67
  event.preventDefault();
68
+ this.heldKey = combo.key;
53
69
  this.app.holdTool(Tool.Pan);
54
70
  return;
55
71
  }
56
- const action = matchShortcut(event);
72
+ const action = shortcuts.find((shortcut) => sameCombo(shortcut, combo))?.action;
57
73
  if (!action) return void 0;
58
74
  event.preventDefault();
59
75
  this.run(action);
60
76
  return action;
61
77
  }
62
78
  /**
63
- * Return to the selected tool when the hold-to-pan key is released.
79
+ * Return to the selected tool when the hold-to-pan key is released (whatever modifiers are held by then).
64
80
  */
65
81
  handleKeyUp(event) {
66
- if (event.key === " ") this.app.releaseHeldTool();
82
+ if (this.heldKey !== void 0 && comboFromEvent(event)?.key === this.heldKey) this.release();
83
+ }
84
+ /** Stop holding the hold-to-pan key */
85
+ release() {
86
+ this.heldKey = void 0;
87
+ this.app.releaseHeldTool();
67
88
  }
68
89
  /**
69
90
  * Run a shortcut action.
@@ -75,21 +96,41 @@ var KeyboardShortcuts = class {
75
96
  app.selectTool(action.tool);
76
97
  break;
77
98
  case "brush-smaller":
78
- case "brush-bigger": {
79
- const settings = app.tool === Tool.Eraser ? app.eraser : app.tool === Tool.Brush ? app.brush : void 0;
80
- if (action.type === "brush-bigger") settings?.grow();
81
- else settings?.shrink();
99
+ this.brushSettings()?.shrink();
100
+ break;
101
+ case "brush-bigger":
102
+ this.brushSettings()?.grow();
103
+ break;
104
+ case "brush-less-opaque":
105
+ this.brushSettings()?.lessOpaque();
106
+ break;
107
+ case "brush-more-opaque":
108
+ this.brushSettings()?.moreOpaque();
82
109
  break;
83
- }
84
110
  case "toggle-interface":
85
111
  app.toggleInterface();
86
112
  break;
87
113
  case "select-all":
88
114
  app.selection.selectAll();
89
115
  break;
116
+ case "invert-selection":
117
+ app.selection.invert();
118
+ break;
90
119
  case "deselect":
91
120
  app.selection.clear();
92
121
  break;
122
+ case "zoom-in":
123
+ app.viewport.zoomIn(this.viewportCentre());
124
+ break;
125
+ case "zoom-out":
126
+ app.viewport.zoomOut(this.viewportCentre());
127
+ break;
128
+ case "reset-zoom":
129
+ app.viewport.zoomAt(this.viewportCentre(), 1);
130
+ break;
131
+ case "layer-opacity":
132
+ app.layers.update(app.layers.active.id, { opacity: action.opacity });
133
+ break;
93
134
  case "undo":
94
135
  app.undo();
95
136
  break;
@@ -102,6 +143,15 @@ var KeyboardShortcuts = class {
102
143
  case "delete":
103
144
  app.deleteMasked();
104
145
  break;
146
+ case "fill-foreground":
147
+ app.fill("foreground");
148
+ break;
149
+ case "fill-background":
150
+ app.fill("background");
151
+ break;
152
+ case "swap-colours":
153
+ app.swapColours();
154
+ break;
105
155
  case "copy":
106
156
  app.copy();
107
157
  break;
@@ -127,6 +177,19 @@ var KeyboardShortcuts = class {
127
177
  default: this.dispatch(action);
128
178
  }
129
179
  }
180
+ /** The brush or eraser settings while that tool is selected */
181
+ brushSettings() {
182
+ const { app } = this;
183
+ return app.tool === Tool.Eraser ? app.eraser : app.tool === Tool.Brush ? app.brush : void 0;
184
+ }
185
+ /** The middle of the viewport, which zoom shortcuts zoom around */
186
+ viewportCentre() {
187
+ const { width, height } = this.app.viewport;
188
+ return {
189
+ x: width / 2,
190
+ y: height / 2
191
+ };
192
+ }
130
193
  /**
131
194
  * Dispatch an action as an `pc-shortcut` event for features handled elsewhere.
132
195
  */
@@ -1,4 +1,6 @@
1
1
  import StrokeLog from "../StrokeLog";
2
+ import { Tool } from "./tool";
3
+ import { isHexColour } from "../../utils/colour";
2
4
  import BackgroundWork from "../BackgroundWork";
3
5
  import type { DrawableLayer } from "../../rendering/compositing";
4
6
  import BrushSettings from "../BrushSettings";
@@ -16,25 +18,13 @@ import type { LayerStoreLike } from "../../storage";
16
18
  import type { AssetAuthor, BrushConfig } from "../../brushes/types";
17
19
  import CustomPresets, { type PresetStoreLike } from "../CustomPresets";
18
20
  import InputSettings from "../InputSettings";
21
+ import Palette from "../Palette";
22
+ import ShortcutSettings from "../ShortcutSettings";
19
23
  import ExternalSheets from "../ExternalSheets";
20
24
  import { type CanvasMessage, type PatchMessage, type StrokeMessage } from "../../messages";
21
25
  import { type BoundingBox } from "../../image";
22
26
  import { type PaintCanvasDocument } from "../../document";
23
- /** Drawing and navigation tools */
24
- export declare enum Tool {
25
- Brush = "brush",
26
- Eraser = "eraser",
27
- /** Move, scale and rotate */
28
- Move = "move",
29
- RectangleLasso = "rectangle-lasso",
30
- CircleLasso = "circle-lasso",
31
- PolygonLasso = "polygon-lasso",
32
- FreeformLasso = "freeform-lasso",
33
- Pan = "pan",
34
- Zoom = "zoom",
35
- /** Set the current colour from the canvas (eyedropper) */
36
- ColourSample = "colour-sample"
37
- }
27
+ export { Tool };
38
28
  /** How the colour sample tool samples: the pixel under the pointer, or the average of a dragged rectangle */
39
29
  export type SampleMode = "point" | "area";
40
30
  /** Toggleable panels */
@@ -43,7 +33,9 @@ export declare enum Panel {
43
33
  Eraser = "eraser",
44
34
  Layers = "layers",
45
35
  /** The implementer's own app menu */
46
- Menu = "menu"
36
+ Menu = "menu",
37
+ /** The colour palette: the foreground and background colours, their values and the saved colours */
38
+ Palette = "palette"
47
39
  }
48
40
  /** Panels opened by tools */
49
41
  export declare const TOOL_PANELS: Partial<Record<Tool, Panel>>;
@@ -51,8 +43,15 @@ export declare const TOOL_PANELS: Partial<Record<Tool, Panel>>;
51
43
  export declare const DEFAULT_CANVAS_SIZE = 2000;
52
44
  /** Largest canvas width or height allowed when the `maxCanvasSize` option isn't given */
53
45
  export declare const MAX_CANVAS_SIZE = 2000;
54
- /** Drawing colour to start with */
46
+ /** Foreground colour to start with */
55
47
  export declare const DEFAULT_COLOUR = "#000000";
48
+ /** Background colour to start with */
49
+ export declare const DEFAULT_BACKGROUND_COLOUR = "#ffffff";
50
+ /**
51
+ * One of the two colours: the foreground colour (painted by the brush and foreground fills) or the background colour
52
+ * (painted by the eraser and Delete when the layer's alpha lock is on, and by background fills).
53
+ */
54
+ export type ColourSlot = "foreground" | "background";
56
55
  /** Owner of the custom presets when the `ownerId` option isn't given */
57
56
  export declare const DEFAULT_OWNER_ID = "local";
58
57
  /** Author name of presets made with `newPreset` when the `presetAuthor` option isn't given */
@@ -65,11 +64,16 @@ export interface PaintCanvasNames {
65
64
  filePrefix: string;
66
65
  /** The `format` written in exported brush preset files */
67
66
  presetFileFormat: string;
67
+ /** The `format` written in exported colour palette files */
68
+ paletteFileFormat: string;
69
+ /** The `format` written in exported settings files */
70
+ settingsFileFormat: string;
68
71
  /** Author name of presets made by a user without a handle, e.g. from `presetAuthor` */
69
72
  unknownAuthor: string;
70
73
  }
71
74
  /** PaintCanvas's own names */
72
75
  export declare const DEFAULT_NAMES: Readonly<PaintCanvasNames>;
76
+ export { isHexColour };
73
77
  /** How much the canvas holds, e.g. to show in a stats panel */
74
78
  export interface CanvasStats {
75
79
  /** Canvas width in pixels */
@@ -123,7 +127,7 @@ export interface PaintCanvasOptions {
123
127
  layerStore?: LayerStoreLike;
124
128
  }
125
129
  /**
126
- * The painting engine: canvas size, layers, brush and eraser settings, custom presets, the selected tool, colour,
130
+ * The painting engine: canvas size, layers, brush and eraser settings, custom presets, the selected tool, foreground and background colours,
127
131
  * selection, transforms, clipboard, undo/redo history, viewport and input settings. Where its data is kept and who it
128
132
  * is shared with are up to the implementer (see `onMessage` and `receive`).
129
133
  *
@@ -134,6 +138,7 @@ export interface PaintCanvasOptions {
134
138
  * const canvas = new PaintCanvas({ width: 1000, height: 800 });
135
139
  * canvas.selectTool(Tool.Brush, { fromToolbar: true });
136
140
  * canvas.setColour("#ff0000");
141
+ * canvas.setColour("#ffffff", "background");
137
142
  */
138
143
  export default class PaintCanvas extends EventTarget {
139
144
  /** Brush settings */
@@ -154,6 +159,10 @@ export default class PaintCanvas extends EventTarget {
154
159
  readonly background: BackgroundWork;
155
160
  /** How the user's input devices control the canvas (scrolling, pinching, touch gestures, focus pen mode) */
156
161
  readonly input: InputSettings;
162
+ /** The user's saved colours, shown as swatches */
163
+ readonly palette: Palette;
164
+ /** The user's keyboard shortcuts: each configurable action's key combination */
165
+ readonly shortcuts: ShortcutSettings;
157
166
  /** The user's pointer position over the canvas, in canvas pixels (undefined when off the canvas) */
158
167
  pointer?: Point;
159
168
  /** Selected tool */
@@ -190,7 +199,8 @@ export default class PaintCanvas extends EventTarget {
190
199
  private sendingStrokes;
191
200
  /** Whether a stroke from elsewhere is being drawn (it isn't sent again) */
192
201
  private replaying;
193
- private currentColour;
202
+ private currentColours;
203
+ private currentSelectedColour;
194
204
  /** Image data copied with cut or copy, and the area it was copied from */
195
205
  private clipboard?;
196
206
  /** Drawings from elsewhere */
@@ -416,24 +426,45 @@ export default class PaintCanvas extends EventTarget {
416
426
  */
417
427
  copy(): Rect | undefined;
418
428
  /**
419
- * Copy the masked image data of the selected layer to the clipboard, then erase it from the layer as one undoable
420
- * action.
429
+ * Copy the masked image data of the selected layer to the clipboard, then remove it from the layer as one undoable
430
+ * action (see `deleteMasked`).
421
431
  */
422
432
  cut(): void;
423
433
  /**
424
- * Erase the masked image data from the selected layer as one undoable action (Delete or Backspace). Needs a mask with
434
+ * Erase the masked image data from the selected layer as one undoable action (Delete or Backspace). When the layer's
435
+ * alpha lock is on, it is painted with the background colour instead, keeping its transparency. Needs a mask with
425
436
  * image data inside it; does nothing during a stroke or while a transform is pending.
426
437
  */
427
438
  deleteMasked(): void;
439
+ /**
440
+ * Fill the selected layer with the foreground or background colour as one undoable action: only the masked area when
441
+ * there is a mask (partly where it is partly), and only existing pixels when the layer's alpha lock is on. Does
442
+ * nothing during a stroke or while a transform is pending.
443
+ *
444
+ * @example
445
+ * canvas.fill("foreground");
446
+ */
447
+ fill(slot: ColourSlot): void;
448
+ /**
449
+ * The area a fill changes: the mask's bounds (or the whole canvas without a mask), cut to the selected layer's image
450
+ * data when its alpha lock is on. None if that is empty.
451
+ */
452
+ private fillArea;
428
453
  /**
429
454
  * The bounds of the selected layer's image data inside the mask, if there is a mask with image data inside it. None
430
455
  * during a stroke or while a transform is pending.
431
456
  */
432
457
  private maskedArea;
433
458
  /**
434
- * Erase the mask's part of an area from the selected layer, as one undoable action.
459
+ * Erase the mask's part of an area from the selected layer, or paint it with the background colour when the layer's
460
+ * alpha lock is on, as one undoable action.
435
461
  */
436
- private eraseMasked;
462
+ private clearMasked;
463
+ /**
464
+ * Paint a colour over an area of the selected layer, cut to the mask if there is one (partly where it is partly), as
465
+ * one undoable action. Only existing pixels are painted when the layer's alpha lock is on.
466
+ */
467
+ private paintArea;
437
468
  /**
438
469
  * Show the clipboard's image data over the selected layer where it was copied from, and select the Move tool to place,
439
470
  * scale and rotate it. It is written into the layer (and sent) on Enter or when switching to another tool; Escape
@@ -468,12 +499,31 @@ export default class PaintCanvas extends EventTarget {
468
499
  get activeTool(): Tool;
469
500
  /** The user's layers */
470
501
  get layers(): LayerStack;
471
- /** Current drawing colour as a hex string, e.g. `#000000` */
502
+ /** Current foreground colour as a hex string, e.g. `#000000` (the same as `colours.foreground`) */
472
503
  get colour(): string;
504
+ /** Current foreground and background colours as hex strings, e.g. `{ foreground: "#000000", background: "#ffffff" }` */
505
+ get colours(): Readonly<Record<ColourSlot, string>>;
506
+ /** Which colour is selected for configuration, e.g. by a colour picker or the colour sample tool ("foreground" by default) */
507
+ get selectedColour(): ColourSlot;
508
+ /**
509
+ * Set the foreground colour, or the background colour. Must be a 6-digit hex colour, e.g. `#ff0000`.
510
+ *
511
+ * @example
512
+ * canvas.setColour("#ff0000"); // foreground
513
+ * canvas.setColour("#ffffff", "background");
514
+ */
515
+ setColour(colour: string, slot?: ColourSlot): void;
516
+ /**
517
+ * Select which colour is configured, e.g. by a colour picker or the colour sample tool. One colour is always selected.
518
+ *
519
+ * @example
520
+ * canvas.selectColour("background");
521
+ */
522
+ selectColour(slot: ColourSlot): void;
473
523
  /**
474
- * Set the drawing colour. Must be a 6-digit hex colour, e.g. `#ff0000`.
524
+ * Swap the foreground and background colours. The selected colour stays the same slot.
475
525
  */
476
- setColour(colour: string): void;
526
+ swapColours(): void;
477
527
  /**
478
528
  * Change the canvas size, from 1 to the maximum canvas size (`maxCanvasSize` option). Replaces all layers.
479
529
  */