signalk-chiplog 1.1.0 → 1.2.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,19 @@ All notable changes to Chiplog are documented here. The format follows [Keep a C
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [1.2.0] - 2026-09-15
8
+
9
+ ### Added
10
+
11
+ - The tablet app's handwriting pad now fills the whole screen and has a toolbar: fine pen, thick pen, highlighter, eraser, undo and a choice of colour (kept to the theme's colour in night mode). The eraser removes only the points it touches, splitting a stroke instead of deleting all of it; undo now steps back through erasing too, not just strokes. A stroke's colour and tool travel with it to the webapp's timeline and the PDF export, not just the tablet.
12
+
13
+ ### Fixed
14
+
15
+ - The tablet app's stylus canvas now prevents the default action on every contact, not just the pen's — a resting palm's touch was left to the browser, which could hijack it as a gesture and cancel the pen's in-progress stroke, or show a native text-selection highlight over the canvas. iOS Safari's long-press selection callout on the canvas needed the whole entry app, not just the canvas, to opt out of selection to reliably stay away, plus blocking `selectstart`/`contextmenu`/`dragstart` directly since the CSS alone is unreliable on some iOS versions.
16
+ - Quickly lifting and reapplying the pen could have its next stroke silently dropped: the previous contact's pointerup can arrive after the next one's pointerdown, which read as "still drawing" and refused to start the new stroke.
17
+ - Worked around an iPadOS Safari/Scribble bug that could swallow a pen's pointer events mid-stroke, dropping strokes or having them mistakenly typed into the comment field, by also preventing the canvas's underlying touch events directly, not just the pointer ones.
18
+ - An autopilot engagement, disengagement or mode change now takes an instrument snapshot like every other automatic event, instead of logging the change with no conditions attached.
19
+
7
20
  ## [1.1.0] - 2026-09-15
8
21
 
9
22
  ### Added
@@ -76,6 +89,7 @@ First release.
76
89
  - REST API under `/plugins/signalk-chiplog/api`, documented in [docs/API.md](docs/API.md).
77
90
  - Single SQLite database through Node's built-in `node:sqlite`: no native module to build.
78
91
 
79
- [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v1.1.0...HEAD
92
+ [Unreleased]: https://github.com/ricard33/signalk-chiplog/compare/v1.2.0...HEAD
93
+ [1.2.0]: https://github.com/ricard33/signalk-chiplog/compare/v1.1.0...v1.2.0
80
94
  [1.1.0]: https://github.com/ricard33/signalk-chiplog/compare/v1.0.0...v1.1.0
81
95
  [1.0.0]: https://github.com/ricard33/signalk-chiplog/releases/tag/v1.0.0
package/README.md CHANGED
@@ -153,9 +153,9 @@ Other entries made with no passage open go to the last passage if the boat is st
153
153
  ### Notes and handwriting
154
154
 
155
155
  - **Note** — type and tap **Log it**.
156
- - **Handwriting** — write or draw on the pad with a stylus or a finger, optionally add a comment, and tap **Log it**. Pen pressure sets the line width. **Undo stroke** and **Clear** fix mistakes. Once a stylus has touched the pad, fingers are ignored, so a palm resting on the screen does not draw.
156
+ - **Handwriting** — takes over the whole screen, with a toolbar above the pad: fine pen, thick pen, highlighter, eraser, undo, and a choice of colour (kept to the theme's colour in night mode, to spare night vision). Pen pressure also sets the line width. The eraser removes only what it actually touches, splitting a stroke rather than deleting all of it; undo steps back through strokes and erasing alike. Once a stylus has touched the pad, fingers are ignored, so a palm resting on the screen does not draw. Add a comment and tap **Log it** to send.
157
157
 
158
- Handwritten notes appear as drawn in the logbook's timeline.
158
+ Handwritten notes appear as drawn — colour, pen or highlighter included — in the logbook's timeline and in the PDF export, not just on the tablet.
159
159
 
160
160
  ### Latest entries
161
161
 
@@ -279,6 +279,10 @@ The USB drive is not mounted at the configured directory, or cannot be written.
279
279
 
280
280
  The server's clock is wrong — common on a Raspberry Pi without a real-time clock. Set it from GPS with `signalk-set-system-time`.
281
281
 
282
+ ### Handwriting strokes are dropped or turn into typed text
283
+
284
+ On an iPad, this is Apple's **Scribble** intercepting the Apple Pencil before the page sees it — a known iPadOS/Safari limitation with no web-page-level fix (Scribble runs beneath the browser). If it happens often, turn Scribble off under **Settings → Apple Pencil → Scribble**; a tablet dedicated to Chiplog does not need it.
285
+
282
286
  ## Limitations
283
287
 
284
288
  - **Not yet:** a places page, and editing manoeuvre shortcuts from the webapps.
@@ -222,7 +222,7 @@ function createEventWatcher({ db, readSelfPath, settings, observe, clock = Date.
222
222
  const target = pilot.engaged ? readAutopilotTarget() : null;
223
223
  logEvent(cycle, 'autopilot', subtype, {
224
224
  payload: { mode: pilot.mode, state: pilot.state, target },
225
- snapshot: false
225
+ snapshot: true
226
226
  });
227
227
  }
228
228
  }
package/lib/events.js CHANGED
@@ -55,6 +55,9 @@ function isFiniteNumber(value) {
55
55
  return typeof value === 'number' && Number.isFinite(value);
56
56
  }
57
57
 
58
+ const HEX_COLOR = /^#[0-9a-f]{6}$/i;
59
+ const STROKE_TOOLS = ['pen', 'highlighter'];
60
+
58
61
  function isValidStrokes(payload) {
59
62
  const strokes = isPlainObject(payload) ? payload.strokes : undefined;
60
63
  return (
@@ -72,7 +75,10 @@ function isValidStrokes(payload) {
72
75
  isFiniteNumber(point.y) &&
73
76
  isFiniteNumber(point.t) &&
74
77
  (point.pressure === undefined || isFiniteNumber(point.pressure))
75
- )
78
+ ) &&
79
+ (stroke.color === undefined || HEX_COLOR.test(stroke.color)) &&
80
+ (stroke.tool === undefined || STROKE_TOOLS.includes(stroke.tool)) &&
81
+ (stroke.width === undefined || (isFiniteNumber(stroke.width) && stroke.width > 0))
76
82
  )
77
83
  );
78
84
  }
@@ -91,7 +97,8 @@ function validateContent(db, { type, subtype, comment, payload }) {
91
97
  }
92
98
  if (type === 'handwritten_annotation' && !isValidStrokes(payload)) {
93
99
  throw badRequest(
94
- 'A handwritten annotation requires payload.strokes: [{ points: [{ x, y, t, pressure? }] }]'
100
+ 'A handwritten annotation requires payload.strokes: ' +
101
+ '[{ points: [{ x, y, t, pressure? }], color?, tool?: "pen"|"highlighter", width? }]'
95
102
  );
96
103
  }
97
104
  }
@@ -18,6 +18,15 @@ const LINE_HEIGHT = 10;
18
18
  const CELL_PADDING = 3;
19
19
  const SKETCH_MAX_HEIGHT = 48;
20
20
 
21
+ // A stroke's base width (SketchPanel.mjs's `fine` pen) when it predates the
22
+ // toolbar and carries no width of its own.
23
+ const DEFAULT_STROKE_WIDTH = 2.5;
24
+
25
+ function hexToRgb(hex) {
26
+ const value = Number.parseInt(hex.slice(1), 16);
27
+ return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff].map((channel) => channel / 255);
28
+ }
29
+
21
30
  const GREY = [0.45, 0.45, 0.45];
22
31
  const RULE = [0.75, 0.75, 0.75];
23
32
  const HEAD_FILL = [0.9, 0.92, 0.94];
@@ -415,7 +424,11 @@ function drawRow(page, row, top) {
415
424
  originX + (point.x - sketch.minX) * sketch.scale,
416
425
  originY + (point.y - sketch.minY) * sketch.scale
417
426
  ]),
418
- { width: 0.8 }
427
+ {
428
+ color: stroke.color ? hexToRgb(stroke.color) : undefined,
429
+ width: (stroke.width ?? DEFAULT_STROKE_WIDTH) * sketch.scale,
430
+ alpha: stroke.tool === 'highlighter'
431
+ }
419
432
  );
420
433
  }
421
434
  }
package/lib/pdf/writer.js CHANGED
@@ -65,8 +65,10 @@ function createPage(width, height) {
65
65
  );
66
66
  },
67
67
 
68
- // Round caps and joins, so a single point still shows as a dot.
69
- polyline(points, { color = [0, 0, 0], width: lineWidth = 1 } = {}) {
68
+ // Round caps and joins, so a single point still shows as a dot. `alpha`
69
+ // draws through the document's one translucency state (a highlighter),
70
+ // rather than at full opacity.
71
+ polyline(points, { color = [0, 0, 0], width: lineWidth = 1, alpha = false } = {}) {
70
72
  if (points.length === 0) {
71
73
  return;
72
74
  }
@@ -75,11 +77,16 @@ function createPage(width, height) {
75
77
  .join(' ');
76
78
  const dot =
77
79
  points.length === 1 ? ` ${number(points[0][0])} ${number(y(points[0][1]))} l` : '';
78
- ops.push(`q ${colour(color)} RG ${number(lineWidth)} w 1 J 1 j ${path}${dot} S Q`);
80
+ const gs = alpha ? '/GS1 gs ' : '';
81
+ ops.push(`q ${gs}${colour(color)} RG ${number(lineWidth)} w 1 J 1 j ${path}${dot} S Q`);
79
82
  }
80
83
  };
81
84
  }
82
85
 
86
+ // The one translucency level the writer knows: a highlighter over ink, over
87
+ // the paper. Only allocated once, referenced from any page that needs it.
88
+ const HIGHLIGHTER_ALPHA = 0.35;
89
+
83
90
  function createPdf({ title = '', author = '', creator = '', creationDate = new Date() } = {}) {
84
91
  const pages = [];
85
92
 
@@ -91,19 +98,20 @@ function createPdf({ title = '', author = '', creator = '', creationDate = new D
91
98
  },
92
99
 
93
100
  finish() {
94
- // Object numbers: 1 catalog, 2 page tree, 3–4 fonts, 5 info, then a page
95
- // and its content stream for each page.
96
- const pageRef = (index) => 6 + index * 2;
101
+ // Object numbers: 1 catalog, 2 page tree, 3–4 fonts, 5 the highlighter's
102
+ // ExtGState, 6 info, then a page and its content stream for each page.
103
+ const pageRef = (index) => 7 + index * 2;
97
104
  const objects = [
98
105
  '<< /Type /Catalog /Pages 2 0 R >>',
99
106
  `<< /Type /Pages /Kids [${pages.map((_, index) => `${pageRef(index)} 0 R`).join(' ')}] /Count ${pages.length} >>`,
100
107
  '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>',
101
108
  '<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>',
109
+ `<< /Type /ExtGState /ca ${number(HIGHLIGHTER_ALPHA)} /CA ${number(HIGHLIGHTER_ALPHA)} >>`,
102
110
  `<< /Title ${infoString(title)} /Author ${infoString(author)} /Creator ${infoString(creator)} /Producer ${infoString(creator)} /CreationDate ${pdfDate(creationDate)} >>`
103
111
  ];
104
112
  pages.forEach((page, index) => {
105
113
  objects.push(
106
- `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${number(page.width)} ${number(page.height)}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> >> /Contents ${pageRef(index) + 1} 0 R >>`
114
+ `<< /Type /Page /Parent 2 0 R /MediaBox [0 0 ${number(page.width)} ${number(page.height)}] /Resources << /Font << /F1 3 0 R /F2 4 0 R >> /ExtGState << /GS1 5 0 R >> >> /Contents ${pageRef(index) + 1} 0 R >>`
107
115
  );
108
116
  const content = zlib.deflateSync(Buffer.from(page.ops.join('\n'), 'latin1'));
109
117
  objects.push({
@@ -137,7 +145,7 @@ function createPdf({ title = '', author = '', creator = '', creationDate = new D
137
145
  '0000000000 65535 f ',
138
146
  ...offsets.map((offset) => `${String(offset).padStart(10, '0')} 00000 n `),
139
147
  'trailer',
140
- `<< /Size ${objects.length + 1} /Root 1 0 R /Info 5 0 R >>`,
148
+ `<< /Size ${objects.length + 1} /Root 1 0 R /Info 6 0 R >>`,
141
149
  'startxref',
142
150
  String(xrefOffset),
143
151
  '%%EOF',
@@ -149,4 +157,4 @@ function createPdf({ title = '', author = '', creator = '', creationDate = new D
149
157
  };
150
158
  }
151
159
 
152
- module.exports = { createPdf };
160
+ module.exports = { createPdf, HIGHLIGHTER_ALPHA };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-chiplog",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Automated digital logbook plugin for Signal K, with keyboard/handwritten entry on tablet",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -76,6 +76,12 @@ body {
76
76
  background: var(--bg);
77
77
  color: var(--text);
78
78
  -webkit-tap-highlight-color: transparent;
79
+ /* Form controls keep their own text selection regardless of this. iOS Safari's
80
+ long-press selection/callout on non-text content (the sketch canvas) only
81
+ reliably backs off when this is set this high up, not just on the element. */
82
+ user-select: none;
83
+ -webkit-user-select: none;
84
+ -webkit-touch-callout: none;
79
85
  }
80
86
 
81
87
  body {
@@ -291,6 +297,44 @@ textarea:focus-visible {
291
297
  }
292
298
  }
293
299
 
300
+ /* Handwriting takes the whole screen while it is the active tab: a small
301
+ writing area is the opposite of what a stylus needs. */
302
+ .writer.fullscreen {
303
+ position: fixed;
304
+ inset: 0;
305
+ z-index: 30;
306
+ margin: 0;
307
+ border-radius: 0;
308
+ display: flex;
309
+ flex-direction: column;
310
+ }
311
+
312
+ .writer.fullscreen .tabs {
313
+ flex: none;
314
+ }
315
+
316
+ .writer.fullscreen .tab-panel {
317
+ flex: 1;
318
+ min-height: 0;
319
+ display: flex;
320
+ }
321
+
322
+ .writer.fullscreen .sketch {
323
+ flex: 1;
324
+ min-height: 0;
325
+ display: flex;
326
+ flex-direction: column;
327
+ }
328
+
329
+ .writer.fullscreen .sketch-surface {
330
+ flex: 1;
331
+ min-height: 0;
332
+ }
333
+
334
+ .writer.fullscreen .sketch-canvas {
335
+ height: 100%;
336
+ }
337
+
294
338
  .panel {
295
339
  background: var(--surface);
296
340
  border: 1px solid var(--border);
@@ -366,8 +410,47 @@ input {
366
410
  gap: 0.75rem;
367
411
  }
368
412
 
413
+ .sketch-toolbar {
414
+ display: flex;
415
+ flex-wrap: wrap;
416
+ align-items: center;
417
+ gap: 0.5rem;
418
+ }
419
+
420
+ .sketch-tools {
421
+ display: flex;
422
+ flex-wrap: wrap;
423
+ gap: 0.4rem;
424
+ }
425
+
426
+ .sketch-colors {
427
+ display: flex;
428
+ flex-wrap: wrap;
429
+ gap: 0.4rem;
430
+ margin-left: auto;
431
+ }
432
+
433
+ .color-swatch {
434
+ width: 2.25rem;
435
+ height: 2.25rem;
436
+ padding: 0;
437
+ border: 2px solid var(--border);
438
+ border-radius: 999px;
439
+ cursor: pointer;
440
+ touch-action: manipulation;
441
+ }
442
+
443
+ .color-swatch[aria-pressed='true'] {
444
+ box-shadow:
445
+ 0 0 0 2px var(--surface),
446
+ 0 0 0 4px var(--accent);
447
+ }
448
+
369
449
  .sketch-surface {
370
450
  position: relative;
451
+ user-select: none;
452
+ -webkit-user-select: none;
453
+ -webkit-touch-callout: none;
371
454
  }
372
455
 
373
456
  .sketch-canvas {
@@ -379,9 +462,16 @@ input {
379
462
  background: var(--paper);
380
463
  color: var(--ink);
381
464
  touch-action: none;
465
+ user-select: none;
466
+ -webkit-user-select: none;
467
+ -webkit-touch-callout: none;
382
468
  cursor: crosshair;
383
469
  }
384
470
 
471
+ .sketch-canvas.erasing {
472
+ cursor: cell;
473
+ }
474
+
385
475
  .sketch-hint {
386
476
  position: absolute;
387
477
  inset: 0;
@@ -18,6 +18,95 @@ export function PencilIcon() {
18
18
  </svg>`;
19
19
  }
20
20
 
21
+ export function FinePenIcon() {
22
+ return html`<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false">
23
+ <path
24
+ d="M13.3 2.7 17.3 6.7 7 17 2.5 17.5 3 13 Z"
25
+ fill="none"
26
+ stroke="currentColor"
27
+ stroke-width="1.2"
28
+ stroke-linejoin="round"
29
+ stroke-linecap="round"
30
+ />
31
+ <path d="M11.3 4.7 15.3 8.7" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
32
+ </svg>`;
33
+ }
34
+
35
+ export function ThickPenIcon() {
36
+ return html`<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false">
37
+ <path
38
+ d="M13.3 2.7 17.3 6.7 7 17 2.5 17.5 3 13 Z"
39
+ fill="currentColor"
40
+ fill-opacity="0.2"
41
+ stroke="currentColor"
42
+ stroke-width="2.2"
43
+ stroke-linejoin="round"
44
+ stroke-linecap="round"
45
+ />
46
+ <path d="M11.3 4.7 15.3 8.7" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" />
47
+ </svg>`;
48
+ }
49
+
50
+ export function HighlighterIcon() {
51
+ return html`<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false">
52
+ <path
53
+ d="M6 12.5 12.5 6l3 3-6.5 6.5z"
54
+ fill="currentColor"
55
+ fill-opacity="0.35"
56
+ stroke="currentColor"
57
+ stroke-width="1.4"
58
+ stroke-linejoin="round"
59
+ />
60
+ <path
61
+ d="M6 12.5 3.5 17l4.5-2.5z"
62
+ fill="currentColor"
63
+ stroke="currentColor"
64
+ stroke-width="1.2"
65
+ stroke-linejoin="round"
66
+ />
67
+ </svg>`;
68
+ }
69
+
70
+ export function EraserIcon() {
71
+ return html`<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false">
72
+ <g transform="rotate(-25 10 10)">
73
+ <rect
74
+ x="4"
75
+ y="6"
76
+ width="12"
77
+ height="8"
78
+ rx="1.6"
79
+ fill="none"
80
+ stroke="currentColor"
81
+ stroke-width="1.6"
82
+ stroke-linejoin="round"
83
+ />
84
+ <line x1="4" y1="10.5" x2="16" y2="10.5" stroke="currentColor" stroke-width="1.6" />
85
+ </g>
86
+ </svg>`;
87
+ }
88
+
89
+ export function UndoIcon() {
90
+ return html`<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false">
91
+ <path
92
+ d="M5 8H12a4 4 0 0 1 0 8H8"
93
+ fill="none"
94
+ stroke="currentColor"
95
+ stroke-width="1.7"
96
+ stroke-linecap="round"
97
+ stroke-linejoin="round"
98
+ />
99
+ <path
100
+ d="M8 4.5 4.5 8 8 11.5"
101
+ fill="none"
102
+ stroke="currentColor"
103
+ stroke-width="1.7"
104
+ stroke-linecap="round"
105
+ stroke-linejoin="round"
106
+ />
107
+ </svg>`;
108
+ }
109
+
21
110
  export function TrashIcon() {
22
111
  return html`<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true" focusable="false">
23
112
  <path d="M4 6h12" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
@@ -1,48 +1,92 @@
1
1
  import { html, useEffect, useRef, useState } from '../../../vendor/preact-htm.mjs';
2
2
  import { useLocale } from '../../../js/context.mjs';
3
3
  import { createStrokeRecorder, strokeWidth } from '../strokes.mjs';
4
+ import { EraserIcon, FinePenIcon, HighlighterIcon, ThickPenIcon, UndoIcon } from './Icons.mjs';
4
5
 
5
- const BASE_WIDTH = 2.5;
6
+ // Presets for the toolbar: base width in canvas CSS pixels, before pressure
7
+ // scaling (SPEC §4.4). Only the highlighter is drawn with transparency.
8
+ const TOOLS = {
9
+ fine: { kind: 'pen', width: 2.5, icon: FinePenIcon, labelKey: 'entry.toolFine' },
10
+ large: { kind: 'pen', width: 5.5, icon: ThickPenIcon, labelKey: 'entry.toolLarge' },
11
+ highlighter: {
12
+ kind: 'highlighter',
13
+ width: 16,
14
+ icon: HighlighterIcon,
15
+ labelKey: 'entry.toolHighlighter'
16
+ }
17
+ };
18
+ const TOOL_ORDER = ['fine', 'large', 'highlighter'];
19
+ const HIGHLIGHTER_ALPHA = 0.35;
20
+ const ERASER_RADIUS = 12;
21
+ const DEFAULT_COLOR = '#0f1b26';
22
+
23
+ // The first choice keeps the theme's ink colour (so a note drawn without
24
+ // picking a colour looks the same as before this toolbar existed); the rest
25
+ // are fixed so the note looks the same everywhere it is later shown.
26
+ const COLORS = [
27
+ { id: 'auto', value: null, labelKey: 'entry.colorDefault' },
28
+ { id: 'blue', value: '#1d4ed8', labelKey: 'entry.colorBlue' },
29
+ { id: 'red', value: '#dc2626', labelKey: 'entry.colorRed' },
30
+ { id: 'green', value: '#16a34a', labelKey: 'entry.colorGreen' },
31
+ { id: 'amber', value: '#d97706', labelKey: 'entry.colorAmber' }
32
+ ];
33
+
34
+ function toHex(cssColor) {
35
+ const channels = cssColor.match(/\d+/g);
36
+ if (!channels) {
37
+ return DEFAULT_COLOR;
38
+ }
39
+ return `#${channels
40
+ .slice(0, 3)
41
+ .map((channel) => Number(channel).toString(16).padStart(2, '0'))
42
+ .join('')}`;
43
+ }
6
44
 
7
45
  function drawStroke(context, stroke) {
8
46
  const { points } = stroke;
47
+ const base = stroke.width ?? TOOLS.fine.width;
48
+ context.strokeStyle = stroke.color ?? DEFAULT_COLOR;
49
+ context.fillStyle = stroke.color ?? DEFAULT_COLOR;
50
+ context.globalAlpha = stroke.tool === 'highlighter' ? HIGHLIGHTER_ALPHA : 1;
9
51
  if (points.length === 1) {
10
52
  const [point] = points;
11
53
  context.beginPath();
12
- context.arc(point.x, point.y, strokeWidth(BASE_WIDTH, point.pressure) / 2, 0, Math.PI * 2);
54
+ context.arc(point.x, point.y, strokeWidth(base, point.pressure) / 2, 0, Math.PI * 2);
13
55
  context.fill();
14
- return;
15
- }
16
- for (let i = 1; i < points.length; i += 1) {
17
- const from = points[i - 1];
18
- const to = points[i];
19
- context.lineWidth = strokeWidth(BASE_WIDTH, to.pressure);
20
- context.beginPath();
21
- context.moveTo(from.x, from.y);
22
- context.lineTo(to.x, to.y);
23
- context.stroke();
56
+ } else {
57
+ for (let i = 1; i < points.length; i += 1) {
58
+ const from = points[i - 1];
59
+ const to = points[i];
60
+ context.lineWidth = strokeWidth(base, to.pressure);
61
+ context.beginPath();
62
+ context.moveTo(from.x, from.y);
63
+ context.lineTo(to.x, to.y);
64
+ context.stroke();
65
+ }
24
66
  }
67
+ context.globalAlpha = 1;
25
68
  }
26
69
 
27
- export function SketchPanel({ busy, onLog }) {
70
+ export function SketchPanel({ busy, onLog, night }) {
28
71
  const { t } = useLocale();
29
72
  const canvas = useRef(null);
30
73
  const recorder = useRef(createStrokeRecorder());
31
74
  const activePointer = useRef(null);
32
75
  // Once a pen has touched the canvas, fingers are the palm resting on it.
33
76
  const penSeen = useRef(false);
77
+ const [tool, setTool] = useState('fine');
78
+ const [color, setColor] = useState(COLORS[0]);
34
79
  const [strokeCount, setStrokeCount] = useState(0);
80
+ const [canUndo, setCanUndo] = useState(false);
35
81
  const [comment, setComment] = useState('');
36
82
 
37
- function context() {
38
- const element = canvas.current;
39
- const drawing = element.getContext('2d');
40
- drawing.lineCap = 'round';
41
- drawing.lineJoin = 'round';
42
- const colour = getComputedStyle(element).color;
43
- drawing.strokeStyle = colour;
44
- drawing.fillStyle = colour;
45
- return drawing;
83
+ function syncState() {
84
+ setStrokeCount(recorder.current.strokes.length);
85
+ setCanUndo(recorder.current.canUndo());
86
+ }
87
+
88
+ function resolveColor() {
89
+ return color.value ?? toHex(getComputedStyle(canvas.current).color);
46
90
  }
47
91
 
48
92
  function redraw() {
@@ -54,7 +98,9 @@ export function SketchPanel({ busy, onLog }) {
54
98
  const { width, height } = element.getBoundingClientRect();
55
99
  element.width = Math.round(width * ratio);
56
100
  element.height = Math.round(height * ratio);
57
- const drawing = context();
101
+ const drawing = element.getContext('2d');
102
+ drawing.lineCap = 'round';
103
+ drawing.lineJoin = 'round';
58
104
  drawing.setTransform(ratio, 0, 0, ratio, 0, 0);
59
105
  drawing.clearRect(0, 0, width, height);
60
106
  recorder.current.strokes.forEach((stroke) => drawStroke(drawing, stroke));
@@ -73,23 +119,52 @@ export function SketchPanel({ busy, onLog }) {
73
119
  };
74
120
  const pressureOf = (event) => (event.pointerType === 'pen' ? event.pressure : undefined);
75
121
 
122
+ // Belt-and-braces alongside the CSS `user-select`/`-webkit-touch-callout: none`:
123
+ // some iOS Safari versions still raise the selection/lookup callout on a long
124
+ // press over the canvas despite that CSS, so block it at the event level too.
125
+ // Also covers a WebKit bug (https://bugs.webkit.org/show_bug.cgi?id=217430): with
126
+ // Scribble on, Safari can swallow a pen's pointer events mid-stroke unless the
127
+ // underlying touchstart/touchmove is prevented directly, not just the pointer one.
128
+ const suppressDefault = (event) => event.preventDefault();
129
+
76
130
  const onPointerDown = (event) => {
131
+ event.preventDefault();
77
132
  if (event.pointerType === 'pen') {
78
133
  penSeen.current = true;
79
134
  } else if (event.pointerType === 'touch' && penSeen.current) {
80
135
  return;
81
136
  }
82
- if (activePointer.current !== null || busy) {
137
+ if (activePointer.current !== null) {
138
+ if (event.pointerType !== 'pen') {
139
+ return;
140
+ }
141
+ // A pen can only touch one point at a time, so a new pen contact means the
142
+ // previous one has lifted even if its pointerup/pointercancel hasn't arrived
143
+ // yet — quickly reapplying the pen can reorder those events. Finish the
144
+ // stale stroke instead of silently dropping the new one.
145
+ if (tool !== 'eraser') {
146
+ recorder.current.end();
147
+ }
148
+ activePointer.current = null;
149
+ }
150
+ if (busy) {
83
151
  return;
84
152
  }
85
- event.preventDefault();
86
153
  canvas.current.setPointerCapture(event.pointerId);
87
154
  activePointer.current = event.pointerId;
88
155
  const [x, y] = locate(event);
89
- const stroke = recorder.current.begin(x, y, event.timeStamp, pressureOf(event));
90
- const drawing = context();
91
- drawing.setTransform(window.devicePixelRatio || 1, 0, 0, window.devicePixelRatio || 1, 0, 0);
92
- drawStroke(drawing, stroke);
156
+ if (tool === 'eraser') {
157
+ recorder.current.beginErase();
158
+ if (recorder.current.eraseAt(x, y, ERASER_RADIUS)) {
159
+ redraw();
160
+ }
161
+ syncState();
162
+ return;
163
+ }
164
+ const style = { color: resolveColor(), tool: TOOLS[tool].kind, width: TOOLS[tool].width };
165
+ recorder.current.begin(x, y, event.timeStamp, pressureOf(event), style);
166
+ redraw();
167
+ syncState();
93
168
  };
94
169
 
95
170
  const onPointerMove = (event) => {
@@ -97,16 +172,24 @@ export function SketchPanel({ busy, onLog }) {
97
172
  return;
98
173
  }
99
174
  const samples = event.getCoalescedEvents?.() ?? [];
100
- const drawing = context();
101
- drawing.setTransform(window.devicePixelRatio || 1, 0, 0, window.devicePixelRatio || 1, 0, 0);
102
- for (const sample of samples.length > 0 ? samples : [event]) {
103
- const [x, y] = locate(sample);
104
- const stroke = recorder.current.extend(x, y, sample.timeStamp, pressureOf(sample));
105
- const { points } = stroke;
106
- if (points.length > 1) {
107
- drawStroke(drawing, { points: points.slice(-2) });
175
+ const list = samples.length > 0 ? samples : [event];
176
+ if (tool === 'eraser') {
177
+ let changed = false;
178
+ for (const sample of list) {
179
+ const [x, y] = locate(sample);
180
+ changed = recorder.current.eraseAt(x, y, ERASER_RADIUS) || changed;
181
+ }
182
+ if (changed) {
183
+ redraw();
184
+ syncState();
108
185
  }
186
+ return;
187
+ }
188
+ for (const sample of list) {
189
+ const [x, y] = locate(sample);
190
+ recorder.current.extend(x, y, sample.timeStamp, pressureOf(sample));
109
191
  }
192
+ redraw();
110
193
  };
111
194
 
112
195
  const onPointerUp = (event) => {
@@ -114,21 +197,23 @@ export function SketchPanel({ busy, onLog }) {
114
197
  return;
115
198
  }
116
199
  activePointer.current = null;
117
- recorder.current.end();
118
- setStrokeCount(recorder.current.strokes.length);
200
+ if (tool !== 'eraser') {
201
+ recorder.current.end();
202
+ }
203
+ syncState();
119
204
  };
120
205
 
121
206
  const undo = () => {
122
207
  recorder.current.undo();
123
- setStrokeCount(recorder.current.strokes.length);
124
208
  redraw();
209
+ syncState();
125
210
  };
126
211
 
127
212
  const clear = () => {
128
213
  recorder.current.clear();
129
- setStrokeCount(0);
130
214
  setComment('');
131
215
  redraw();
216
+ syncState();
132
217
  };
133
218
 
134
219
  const send = async () => {
@@ -144,16 +229,73 @@ export function SketchPanel({ busy, onLog }) {
144
229
 
145
230
  return html`
146
231
  <div class="sketch">
232
+ <div class="sketch-toolbar" role="toolbar" aria-label=${t('entry.sketchTools')}>
233
+ <div class="sketch-tools">
234
+ ${TOOL_ORDER.map((key) => {
235
+ const preset = TOOLS[key];
236
+ const Icon = preset.icon;
237
+ return html`<button
238
+ type="button"
239
+ key=${key}
240
+ class="tool-button icon-button"
241
+ aria-pressed=${tool === key}
242
+ aria-label=${t(preset.labelKey)}
243
+ onClick=${() => setTool(key)}
244
+ >
245
+ <${Icon} />
246
+ </button>`;
247
+ })}
248
+ <button
249
+ type="button"
250
+ class="tool-button icon-button"
251
+ aria-pressed=${tool === 'eraser'}
252
+ aria-label=${t('entry.toolEraser')}
253
+ onClick=${() => setTool('eraser')}
254
+ >
255
+ <${EraserIcon} />
256
+ </button>
257
+ <button
258
+ type="button"
259
+ class="tool-button icon-button"
260
+ disabled=${!canUndo}
261
+ aria-label=${t('entry.sketchUndo')}
262
+ onClick=${undo}
263
+ >
264
+ <${UndoIcon} />
265
+ </button>
266
+ </div>
267
+ ${
268
+ !night &&
269
+ html`<div class="sketch-colors">
270
+ ${COLORS.map(
271
+ (option) => html`<button
272
+ type="button"
273
+ key=${option.id}
274
+ class="color-swatch"
275
+ style=${{ background: option.value ?? 'var(--ink)' }}
276
+ aria-pressed=${color.id === option.id}
277
+ aria-label=${t(option.labelKey)}
278
+ onClick=${() => setColor(option)}
279
+ ></button>`
280
+ )}
281
+ </div>`
282
+ }
283
+ </div>
147
284
  <div class="sketch-surface">
148
285
  <canvas
149
286
  ref=${canvas}
150
- class="sketch-canvas"
287
+ class="sketch-canvas ${tool === 'eraser' ? 'erasing' : ''}"
151
288
  role="img"
152
289
  aria-label=${t('entry.sketchArea')}
153
290
  onPointerDown=${onPointerDown}
154
291
  onPointerMove=${onPointerMove}
155
292
  onPointerUp=${onPointerUp}
156
293
  onPointerCancel=${onPointerUp}
294
+ onContextMenu=${suppressDefault}
295
+ onSelectStart=${suppressDefault}
296
+ onDragStart=${suppressDefault}
297
+ onTouchStart=${suppressDefault}
298
+ onTouchMove=${suppressDefault}
157
299
  ></canvas>
158
300
  ${strokeCount === 0 && html`<span class="sketch-hint" aria-hidden="true">${t('entry.sketchHint')}</span>`}
159
301
  </div>
@@ -166,9 +308,6 @@ export function SketchPanel({ busy, onLog }) {
166
308
  onInput=${(event) => setComment(event.currentTarget.value)}
167
309
  />
168
310
  <div class="sketch-actions">
169
- <button type="button" class="tool-button" disabled=${strokeCount === 0} onClick=${undo}>
170
- ${t('entry.sketchUndo')}
171
- </button>
172
311
  <button type="button" class="tool-button" disabled=${strokeCount === 0} onClick=${clear}>
173
312
  ${t('entry.sketchClear')}
174
313
  </button>
@@ -285,7 +285,7 @@ function App({ journal }) {
285
285
  busy=${busy}
286
286
  onLog=${log}
287
287
  />
288
- <section class="panel writer">
288
+ <section class="panel writer ${tab === 'sketch' ? 'fullscreen' : ''}">
289
289
  <div class="tabs" role="tablist">
290
290
  ${['note', 'sketch'].map(
291
291
  (name) => html`<button
@@ -300,8 +300,10 @@ function App({ journal }) {
300
300
  </button>`
301
301
  )}
302
302
  </div>
303
- <div hidden=${tab !== 'note'}><${NotePanel} busy=${busy} onLog=${log} /></div>
304
- <div hidden=${tab !== 'sketch'}><${SketchPanel} busy=${busy} onLog=${log} /></div>
303
+ <div class="tab-panel" hidden=${tab !== 'note'}><${NotePanel} busy=${busy} onLog=${log} /></div>
304
+ <div class="tab-panel" hidden=${tab !== 'sketch'}>
305
+ <${SketchPanel} busy=${busy} onLog=${log} night=${night} />
306
+ </div>
305
307
  </section>
306
308
  </div>
307
309
  <${RecentList}
@@ -1,14 +1,29 @@
1
1
  // Records a handwritten note as vector strokes, the format the logbook stores
2
2
  // (SPEC §4.4): points in CSS pixels of the canvas, t in milliseconds from the
3
- // first point of the note, pressure only from a pen.
3
+ // first point of the note, pressure only from a pen. A stroke also carries the
4
+ // tool it was drawn with — color, tool ('pen'/'highlighter') and base width —
5
+ // so the webapp and PDF can reproduce it, not just the tablet.
4
6
 
5
7
  // Rounded through toFixed: multiplying back by a step leaves 38.800000000000004.
6
8
  const round = (value, digits) => Number(value.toFixed(digits));
7
9
 
10
+ // Shortest distance from (px, py) to the segment (x1, y1)-(x2, y2).
11
+ function distanceToSegment(px, py, x1, y1, x2, y2) {
12
+ const dx = x2 - x1;
13
+ const dy = y2 - y1;
14
+ const lengthSquared = dx * dx + dy * dy;
15
+ const t =
16
+ lengthSquared === 0
17
+ ? 0
18
+ : Math.max(0, Math.min(1, ((px - x1) * dx + (py - y1) * dy) / lengthSquared));
19
+ return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy));
20
+ }
21
+
8
22
  export function createStrokeRecorder() {
9
23
  let strokes = [];
10
24
  let current = null;
11
25
  let origin = null;
26
+ let history = [];
12
27
 
13
28
  function point(x, y, time, pressure) {
14
29
  origin ??= time;
@@ -19,9 +34,18 @@ export function createStrokeRecorder() {
19
34
  return recorded;
20
35
  }
21
36
 
37
+ // Snapshots the strokes as they are now, so a single `undo()` can restore
38
+ // them after either a finished pen stroke or a whole eraser gesture.
39
+ function pushHistory() {
40
+ history.push(
41
+ strokes.map((stroke) => ({ ...stroke, points: stroke.points.map((p) => ({ ...p })) }))
42
+ );
43
+ }
44
+
22
45
  return {
23
- begin(x, y, time, pressure) {
24
- current = { points: [point(x, y, time, pressure)] };
46
+ begin(x, y, time, pressure, style) {
47
+ pushHistory();
48
+ current = { points: [point(x, y, time, pressure)], ...style };
25
49
  strokes.push(current);
26
50
  return current;
27
51
  },
@@ -42,9 +66,55 @@ export function createStrokeRecorder() {
42
66
  current = null;
43
67
  },
44
68
 
69
+ // Call once when an eraser gesture starts, then `eraseAt` for each move:
70
+ // the whole gesture undoes as one action, like a finished pen stroke.
71
+ beginErase() {
72
+ pushHistory();
73
+ },
74
+
75
+ // Removes any point of any stroke within `radius` of (x, y), splitting a
76
+ // stroke in two where the erased portion was in its middle. Returns
77
+ // whether anything was actually erased.
78
+ eraseAt(x, y, radius) {
79
+ let changed = false;
80
+ const next = [];
81
+ for (const stroke of strokes) {
82
+ const { points } = stroke;
83
+ let run = [];
84
+ const flushRun = () => {
85
+ if (run.length > 0) {
86
+ next.push({ ...stroke, points: run });
87
+ run = [];
88
+ }
89
+ };
90
+ // Tested against the last point actually kept, not the original array: once a
91
+ // point is erased, the segment picks up from before it, so a straight line
92
+ // erased in its middle still splits into two, instead of eating one more
93
+ // point than intended on the far side of the gap.
94
+ for (const candidate of points) {
95
+ const kept = run.at(-1);
96
+ const hit = kept
97
+ ? distanceToSegment(x, y, kept.x, kept.y, candidate.x, candidate.y) <= radius
98
+ : Math.hypot(candidate.x - x, candidate.y - y) <= radius;
99
+ if (hit) {
100
+ changed = true;
101
+ flushRun();
102
+ } else {
103
+ run.push(candidate);
104
+ }
105
+ }
106
+ flushRun();
107
+ }
108
+ if (changed) {
109
+ strokes = next;
110
+ current = null;
111
+ }
112
+ return changed;
113
+ },
114
+
45
115
  undo() {
116
+ strokes = history.pop() ?? [];
46
117
  current = null;
47
- strokes.pop();
48
118
  if (strokes.length === 0) {
49
119
  origin = null;
50
120
  }
@@ -54,19 +124,29 @@ export function createStrokeRecorder() {
54
124
  strokes = [];
55
125
  current = null;
56
126
  origin = null;
127
+ history = [];
57
128
  },
58
129
 
59
130
  get strokes() {
60
131
  return strokes;
61
132
  },
62
133
 
134
+ canUndo() {
135
+ return history.length > 0;
136
+ },
137
+
63
138
  isEmpty() {
64
139
  return strokes.length === 0;
65
140
  },
66
141
 
67
142
  payload({ width, height }) {
68
143
  return {
69
- strokes: strokes.map((stroke) => ({ points: stroke.points.map((p) => ({ ...p })) })),
144
+ strokes: strokes.map((stroke) => ({
145
+ points: stroke.points.map((p) => ({ ...p })),
146
+ ...(stroke.color !== undefined ? { color: stroke.color } : {}),
147
+ ...(stroke.tool !== undefined ? { tool: stroke.tool } : {}),
148
+ ...(stroke.width !== undefined ? { width: stroke.width } : {})
149
+ })),
70
150
  width: Math.round(width),
71
151
  height: Math.round(height)
72
152
  };
@@ -10,6 +10,10 @@ function strokePoints(stroke) {
10
10
  return (points.length === 1 ? [points[0], points[0]] : points).join(' ');
11
11
  }
12
12
 
13
+ // Kept in step with SketchPanel.mjs and logbook-pdf.js: how translucent a
14
+ // highlighter stroke is, wherever it is later shown.
15
+ const HIGHLIGHTER_OPACITY = 0.35;
16
+
13
17
  export function Strokes({ strokes, label }) {
14
18
  const points = strokes.flatMap((stroke) => stroke.points ?? []);
15
19
  if (points.length === 0) {
@@ -21,6 +25,9 @@ export function Strokes({ strokes, label }) {
21
25
  const minY = Math.min(...ys);
22
26
  const size = Math.max(Math.max(...xs) - minX, Math.max(...ys) - minY, 1);
23
27
  const pad = size * 0.05;
28
+ // Older notes, drawn before the toolbar existed, have no color/width of their
29
+ // own: they fall back to a single relative width in the current text colour.
30
+ const defaultWidth = size / 120;
24
31
  return html`
25
32
  <svg
26
33
  class="strokes"
@@ -28,18 +35,15 @@ export function Strokes({ strokes, label }) {
28
35
  role="img"
29
36
  aria-label=${label}
30
37
  >
31
- <g
32
- fill="none"
33
- stroke="currentColor"
34
- stroke-linecap="round"
35
- stroke-linejoin="round"
36
- stroke-width=${size / 120}
37
- >
38
+ <g fill="none" stroke-linecap="round" stroke-linejoin="round">
38
39
  ${strokes.map(
39
40
  (stroke, index) =>
40
41
  html`<polyline
41
42
  key=${index}
42
43
  points=${strokePoints(stroke)}
44
+ stroke=${stroke.color ?? 'currentColor'}
45
+ stroke-width=${stroke.width ?? defaultWidth}
46
+ stroke-opacity=${stroke.tool === 'highlighter' ? HIGHLIGHTER_OPACITY : 1}
43
47
  />`
44
48
  )}
45
49
  </g>
@@ -237,10 +237,20 @@ export const MESSAGES = {
237
237
  'entry.refused': 'Not logged: {message}',
238
238
  'entry.chooseSail': 'Which sail is up?',
239
239
  'entry.otherSail': 'Other sail',
240
- 'entry.sketchUndo': 'Undo stroke',
240
+ 'entry.sketchUndo': 'Undo',
241
241
  'entry.sketchClear': 'Clear',
242
242
  'entry.sketchArea': 'Handwriting area',
243
243
  'entry.sketchHint': 'Write or draw here',
244
+ 'entry.sketchTools': 'Drawing tools',
245
+ 'entry.toolFine': 'Fine pen',
246
+ 'entry.toolLarge': 'Thick pen',
247
+ 'entry.toolHighlighter': 'Highlighter',
248
+ 'entry.toolEraser': 'Eraser',
249
+ 'entry.colorDefault': 'Theme colour',
250
+ 'entry.colorBlue': 'Blue',
251
+ 'entry.colorRed': 'Red',
252
+ 'entry.colorGreen': 'Green',
253
+ 'entry.colorAmber': 'Amber',
244
254
  'entry.recent': 'Latest entries',
245
255
  'entry.recentEmpty': 'Nothing logged in this passage yet.',
246
256
  'entry.pending': 'Waiting to be sent',
@@ -513,10 +523,20 @@ export const MESSAGES = {
513
523
  'entry.refused': 'Non consigné : {message}',
514
524
  'entry.chooseSail': 'Quelle voile est envoyée ?',
515
525
  'entry.otherSail': 'Autre voile',
516
- 'entry.sketchUndo': 'Effacer le trait',
526
+ 'entry.sketchUndo': 'Annuler',
517
527
  'entry.sketchClear': 'Tout effacer',
518
528
  'entry.sketchArea': 'Zone d’écriture',
519
529
  'entry.sketchHint': 'Écrivez ou dessinez ici',
530
+ 'entry.sketchTools': 'Outils de dessin',
531
+ 'entry.toolFine': 'Crayon fin',
532
+ 'entry.toolLarge': 'Crayon large',
533
+ 'entry.toolHighlighter': 'Surligneur',
534
+ 'entry.toolEraser': 'Gomme',
535
+ 'entry.colorDefault': 'Couleur du thème',
536
+ 'entry.colorBlue': 'Bleu',
537
+ 'entry.colorRed': 'Rouge',
538
+ 'entry.colorGreen': 'Vert',
539
+ 'entry.colorAmber': 'Orange',
520
540
  'entry.recent': 'Dernières saisies',
521
541
  'entry.recentEmpty': 'Rien de consigné dans cette navigation.',
522
542
  'entry.pending': 'En attente d’envoi',