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 +15 -1
- package/README.md +6 -2
- package/lib/event-watcher.js +1 -1
- package/lib/events.js +9 -2
- package/lib/logbook-pdf.js +14 -1
- package/lib/pdf/writer.js +17 -9
- package/package.json +1 -1
- package/public/entry/entry.css +90 -0
- package/public/entry/js/components/Icons.mjs +89 -0
- package/public/entry/js/components/SketchPanel.mjs +184 -45
- package/public/entry/js/main.mjs +5 -3
- package/public/entry/js/strokes.mjs +85 -5
- package/public/js/components/Timeline.mjs +11 -7
- package/public/js/i18n.mjs +22 -2
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.
|
|
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** —
|
|
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.
|
package/lib/event-watcher.js
CHANGED
|
@@ -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:
|
|
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:
|
|
100
|
+
'A handwritten annotation requires payload.strokes: ' +
|
|
101
|
+
'[{ points: [{ x, y, t, pressure? }], color?, tool?: "pen"|"highlighter", width? }]'
|
|
95
102
|
);
|
|
96
103
|
}
|
|
97
104
|
}
|
package/lib/logbook-pdf.js
CHANGED
|
@@ -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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
95
|
-
// and its content stream for each page.
|
|
96
|
-
const pageRef = (index) =>
|
|
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
|
|
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
package/public/entry/entry.css
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
54
|
+
context.arc(point.x, point.y, strokeWidth(base, point.pressure) / 2, 0, Math.PI * 2);
|
|
13
55
|
context.fill();
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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>
|
package/public/entry/js/main.mjs
CHANGED
|
@@ -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'}
|
|
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
|
-
|
|
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) => ({
|
|
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>
|
package/public/js/i18n.mjs
CHANGED
|
@@ -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
|
|
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': '
|
|
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',
|