changed to fixed drawing dimensions

This commit is contained in:
Gamosoft 2026-04-24 15:29:00 +02:00
parent 239e52dfed
commit a0e85045d5
2 changed files with 151 additions and 39 deletions

View File

@ -5,6 +5,17 @@ const CONFIG = {
AUTOSAVE_DELAY: 1000, // ms - Debounce before note save (autoSave) and drawing PNG autosave (_drawingScheduleAutosave) AUTOSAVE_DELAY: 1000, // ms - Debounce before note save (autoSave) and drawing PNG autosave (_drawingScheduleAutosave)
/** Must match drawingRedraw() fill and eraser stroke color (opaque “whiteboard”). */ /** Must match drawingRedraw() fill and eraser stroke color (opaque “whiteboard”). */
DRAWING_BACKGROUND: '#ffffff', DRAWING_BACKGROUND: '#ffffff',
/**
* Drawing document size (intrinsic resolution). The display canvas may render
* smaller on small screens, but pointer events, ops and the saved PNG always
* live in this fixed coordinate space. A future "new drawing" dialog can
* override the dimensions per drawing without any architectural change.
*/
DRAWING_DEFAULT_DOC_W: 1600,
DRAWING_DEFAULT_DOC_H: 1000,
/** Hard bounds applied when (re)loading or creating a drawing. */
DRAWING_MIN_DOC_DIM: 64,
DRAWING_MAX_DOC_DIM: 4096,
SEARCH_DEBOUNCE_DELAY: 500, // ms - Delay before running note search while typing SEARCH_DEBOUNCE_DELAY: 500, // ms - Delay before running note search while typing
SAVE_INDICATOR_DURATION: 2000, // ms - How long to show "saved" indicator SAVE_INDICATOR_DURATION: 2000, // ms - How long to show "saved" indicator
SCROLL_SYNC_DELAY: 50, // ms - Delay to prevent scroll sync interference SCROLL_SYNC_DELAY: 50, // ms - Delay to prevent scroll sync interference
@ -502,11 +513,17 @@ function noteApp() {
currentMedia: '', // Path to current media file (kept as 'currentMedia' for compatibility) currentMedia: '', // Path to current media file (kept as 'currentMedia' for compatibility)
currentMediaType: 'image', // 'image', 'audio', 'video', 'document', 'drawing' currentMediaType: 'image', // 'image', 'audio', 'video', 'document', 'drawing'
// Drawing canvas (drawing-*.png only) — ops are session-only until Save flattens to PNG // Drawing canvas (drawing-*.png only) — ops are session-only until Save flattens to PNG.
// Coordinates in drawingOps[] are stored in DOCUMENT space (drawingDocW × drawingDocH),
// not display CSS pixels, so resizing the editor pane never moves strokes and exported
// PNGs are byte-deterministic regardless of screen size or device pixel ratio.
drawingTool: 'freehand', drawingTool: 'freehand',
drawingColor: '#1a1a1a', drawingColor: '#1a1a1a',
drawingLineWidth: 4, drawingLineWidth: 4,
drawingOps: [], drawingOps: [],
/** Intrinsic drawing dimensions (in document px). Set when a drawing is created or loaded. */
drawingDocW: 1600,
drawingDocH: 1000,
drawingRedoStack: [], drawingRedoStack: [],
drawingDraft: null, drawingDraft: null,
drawingIsPointerDown: false, drawingIsPointerDown: false,
@ -2811,16 +2828,24 @@ function noteApp() {
* Create a blank drawing PNG and open it for editing. * Create a blank drawing PNG and open it for editing.
* Attachment folder matches "New note" / "New folder" from the same + menu (root vs folder row vs homepage folder). * Attachment folder matches "New note" / "New folder" from the same + menu (root vs folder row vs homepage folder).
*/ */
async createNewDrawing() { /**
* Create a brand-new drawing PNG and open it in the editor.
* @param {{docW?: number, docH?: number}} [opts]
* Optional intrinsic dimensions in document pixels. Phase-2: a "New drawing"
* dialog (presets, A4, custom) only needs to pass these values here no other
* plumbing is required because every downstream function reads doc size from
* either the loaded PNG or the in-memory drawingDocW/drawingDocH fields.
*/
async createNewDrawing(opts = {}) {
const targetFolder = this.inferredNewItemTargetFolder(); const targetFolder = this.inferredNewItemTargetFolder();
this.closeDropdown(); this.closeDropdown();
const w = 1200; const w = this._drawingClampDim(opts.docW, CONFIG.DRAWING_DEFAULT_DOC_W);
const h = 675; const h = this._drawingClampDim(opts.docH, CONFIG.DRAWING_DEFAULT_DOC_H);
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = w; canvas.width = w;
canvas.height = h; canvas.height = h;
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff'; ctx.fillStyle = CONFIG.DRAWING_BACKGROUND;
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
let blob; let blob;
try { try {
@ -2848,15 +2873,33 @@ function noteApp() {
return this.currentMedia.split('/').map((s) => encodeURIComponent(s)).join('/'); return this.currentMedia.split('/').map((s) => encodeURIComponent(s)).join('/');
}, },
/**
* Clamp a dimension to the [MIN, MAX] document-px range. Falls back to `fallback`
* when value is missing, non-finite or non-positive. Always returns an integer.
*/
_drawingClampDim(value, fallback) {
const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return Math.round(fallback);
const clamped = Math.min(
CONFIG.DRAWING_MAX_DOC_DIM,
Math.max(CONFIG.DRAWING_MIN_DOC_DIM, n)
);
return Math.round(clamped);
},
/**
* Convert a pointer event to DOCUMENT-space coordinates (0..drawingDocW, 0..drawingDocH).
* The mapping is independent of CSS size, DPR and zoom strokes stored from this
* function render identically on any device and round-trip cleanly through PNG export.
*/
_drawingCanvasCoords(event) { _drawingCanvasCoords(event) {
const canvas = this._drawingCanvasEl; const canvas = this._drawingCanvasEl;
if (!canvas) return { x: 0, y: 0 }; if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const scaleX = this._drawingCssW / rect.width; if (rect.width <= 0 || rect.height <= 0) return { x: 0, y: 0 };
const scaleY = this._drawingCssH / rect.height;
return { return {
x: (event.clientX - rect.left) * scaleX, x: ((event.clientX - rect.left) / rect.width) * this.drawingDocW,
y: (event.clientY - rect.top) * scaleY, y: ((event.clientY - rect.top) / rect.height) * this.drawingDocH,
}; };
}, },
@ -2907,19 +2950,29 @@ function noteApp() {
ctx.restore(); ctx.restore();
}, },
/**
* Repaint the visible canvas. All drawing math runs in DOCUMENT space; a single
* setTransform call maps (docW, docH) device pixels by combining the docdisplay
* scale with the device pixel ratio. _drawingDrawOp itself is space-agnostic.
*/
drawingRedraw() { drawingRedraw() {
const canvas = this._drawingCanvasEl; const canvas = this._drawingCanvasEl;
const ctx = this._drawingCtx; const ctx = this._drawingCtx;
if (!canvas || !ctx) return; if (!canvas || !ctx) return;
const w = this._drawingCssW; const docW = this.drawingDocW;
const h = this._drawingCssH; const docH = this.drawingDocH;
const dpr = this._drawingDpr; const cssW = this._drawingCssW;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); const cssH = this._drawingCssH;
ctx.clearRect(0, 0, w, h); const dpr = this._drawingDpr || 1;
if (!docW || !docH || !cssW || !cssH) return;
const sx = (cssW / docW) * dpr;
const sy = (cssH / docH) * dpr;
ctx.setTransform(sx, 0, 0, sy, 0, 0);
ctx.clearRect(0, 0, docW, docH);
ctx.fillStyle = CONFIG.DRAWING_BACKGROUND; ctx.fillStyle = CONFIG.DRAWING_BACKGROUND;
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, docW, docH);
if (this._drawingBaseImage && this._drawingBaseImage.complete) { if (this._drawingBaseImage && this._drawingBaseImage.complete) {
ctx.drawImage(this._drawingBaseImage, 0, 0, w, h); ctx.drawImage(this._drawingBaseImage, 0, 0, docW, docH);
} }
for (const op of this.drawingOps) { for (const op of this.drawingOps) {
this._drawingDrawOp(ctx, op); this._drawingDrawOp(ctx, op);
@ -2982,24 +3035,52 @@ function noteApp() {
this._drawingAutosaveTimeout = setTimeout(attemptSave, CONFIG.AUTOSAVE_DELAY); this._drawingAutosaveTimeout = setTimeout(attemptSave, CONFIG.AUTOSAVE_DELAY);
}, },
/** Size canvas to drawingCanvasWrap and redraw (fills available pane). */ /**
* Size the visible canvas as a letterbox of (drawingDocW × drawingDocH) inside the
* available wrap, preserving aspect ratio. The display canvas may be smaller than
* the wrap on narrow viewports that is intentional. The document space stays
* fixed; only the on-screen viewport scales.
*/
_drawingLayoutCanvas() { _drawingLayoutCanvas() {
const wrap = this.$refs.drawingCanvasWrap; const wrap = this.$refs.drawingCanvasWrap;
const canvas = this.$refs.drawingCanvas; const canvas = this.$refs.drawingCanvas;
if (!wrap || !canvas || this.currentMediaType !== 'drawing') return; if (!wrap || !canvas || this.currentMediaType !== 'drawing') return;
let cssW = wrap.clientWidth;
let cssH = wrap.clientHeight; // Available area (with sane fallbacks for the brief moment before layout settles).
if (cssW < 32) cssW = Math.max(320, wrap.parentElement ? wrap.parentElement.clientWidth : 320); let availW = wrap.clientWidth;
if (cssH < 32) { let availH = wrap.clientHeight;
if (availW < 32) {
availW = Math.max(320, wrap.parentElement ? wrap.parentElement.clientWidth : 320);
}
if (availH < 32) {
const col = wrap.closest('.flex-1.flex.flex-col') || wrap.closest('.flex-1'); const col = wrap.closest('.flex-1.flex.flex-col') || wrap.closest('.flex-1');
const h = col ? col.clientHeight : 0; const h = col ? col.clientHeight : 0;
cssH = h > 64 ? h : Math.max(240, Math.floor((cssW || 400) * 0.5)); availH = h > 64 ? h : Math.max(240, Math.floor((availW || 400) * 0.5));
} }
// Letterbox the document into the available area.
const docW = this.drawingDocW;
const docH = this.drawingDocH;
const docAspect = docW / docH;
const wrapAspect = availW / availH;
let cssW;
let cssH;
if (wrapAspect > docAspect) {
cssH = availH;
cssW = cssH * docAspect;
} else {
cssW = availW;
cssH = cssW / docAspect;
}
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
canvas.style.width = `${cssW}px`; canvas.style.width = `${cssW}px`;
canvas.style.height = `${cssH}px`; canvas.style.height = `${cssH}px`;
canvas.width = Math.round(cssW * dpr); // Backing store in device pixels — capped above by availW/availH * dpr, so this
canvas.height = Math.round(cssH * dpr); // never allocates more than the viewport could ever show. Cheap on every device.
canvas.width = Math.max(1, Math.round(cssW * dpr));
canvas.height = Math.max(1, Math.round(cssH * dpr));
this._drawingCanvasEl = canvas; this._drawingCanvasEl = canvas;
this._drawingCssW = cssW; this._drawingCssW = cssW;
this._drawingCssH = cssH; this._drawingCssH = cssH;
@ -3007,7 +3088,6 @@ function noteApp() {
if (!this._drawingCtx) { if (!this._drawingCtx) {
this._drawingCtx = canvas.getContext('2d'); this._drawingCtx = canvas.getContext('2d');
} }
this._drawingCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.drawingRedraw(); this.drawingRedraw();
}, },
@ -3029,6 +3109,11 @@ function noteApp() {
this.drawingIsPointerDown = false; this.drawingIsPointerDown = false;
this._drawingBaseImage = null; this._drawingBaseImage = null;
this.drawingHasRasterFromFile = false; this.drawingHasRasterFromFile = false;
// Reset to defaults; replaced by the loaded PNG's natural size below.
// For drawings created via createNewDrawing() the file already matches the defaults
// (or the size requested by a future "new drawing" dialog).
this.drawingDocW = CONFIG.DRAWING_DEFAULT_DOC_W;
this.drawingDocH = CONFIG.DRAWING_DEFAULT_DOC_H;
this._drawingLoadToken = Symbol(); this._drawingLoadToken = Symbol();
const token = this._drawingLoadToken; const token = this._drawingLoadToken;
@ -3064,6 +3149,10 @@ function noteApp() {
if (token !== this._drawingLoadToken) return; if (token !== this._drawingLoadToken) return;
this._drawingBaseImage = img; this._drawingBaseImage = img;
this.drawingHasRasterFromFile = true; this.drawingHasRasterFromFile = true;
// Adopt the PNG's intrinsic size as the document size for this drawing.
// The PNG itself is the source of truth — no sidecar metadata required.
this.drawingDocW = this._drawingClampDim(img.naturalWidth, CONFIG.DRAWING_DEFAULT_DOC_W);
this.drawingDocH = this._drawingClampDim(img.naturalHeight, CONFIG.DRAWING_DEFAULT_DOC_H);
this._drawingLayoutCanvas(); this._drawingLayoutCanvas();
} catch (e) { } catch (e) {
if (token !== this._drawingLoadToken) return; if (token !== this._drawingLoadToken) return;
@ -3081,16 +3170,22 @@ function noteApp() {
return `#${h(r)}${h(g)}${h(b)}`; return `#${h(r)}${h(g)}${h(b)}`;
}, },
/** Sample visible canvas color at logical (css) coordinates; sets drawingColor. */ /**
* Sample visible canvas color under the pointer; sets drawingColor.
* Pointer coords come in document space; we map to device pixels via the canvas's
* actual backing store (which already includes both the docdisplay scale and DPR).
*/
drawingSampleColor(e) { drawingSampleColor(e) {
const canvas = this._drawingCanvasEl; const canvas = this._drawingCanvasEl;
const ctx = this._drawingCtx; const ctx = this._drawingCtx;
if (!canvas || !ctx) return; if (!canvas || !ctx) return;
const docW = this.drawingDocW;
const docH = this.drawingDocH;
if (!docW || !docH) return;
const { x, y } = this._drawingCanvasCoords(e); const { x, y } = this._drawingCanvasCoords(e);
this.drawingRedraw(); this.drawingRedraw();
const dpr = this._drawingDpr || 1; let ix = Math.floor((x / docW) * canvas.width);
let ix = Math.floor(x * dpr); let iy = Math.floor((y / docH) * canvas.height);
let iy = Math.floor(y * dpr);
ix = Math.max(0, Math.min(ix, canvas.width - 1)); ix = Math.max(0, Math.min(ix, canvas.width - 1));
iy = Math.max(0, Math.min(iy, canvas.height - 1)); iy = Math.max(0, Math.min(iy, canvas.height - 1));
const pix = ctx.getImageData(ix, iy, 1, 1).data; const pix = ctx.getImageData(ix, iy, 1, 1).data;
@ -3283,15 +3378,32 @@ function noteApp() {
this._drawingSaveInFlight = true; this._drawingSaveInFlight = true;
try { try {
this._drawingCancelAutosave(); this._drawingCancelAutosave();
const canvas = this._drawingCanvasEl;
const ctx = this._drawingCtx;
if (!canvas || !ctx) return;
this.drawingDraft = null; this.drawingDraft = null;
this.drawingIsPointerDown = false; this.drawingIsPointerDown = false;
// Render onto an off-screen canvas at the exact document size. This makes the
// exported PNG byte-deterministic across devices: same ops, same dimensions →
// identical bytes regardless of DPR, window size, or zoom level. The off-screen
// canvas is allocated per save (microseconds for 1600×1000) so dimension changes
// from a future "resize drawing" feature can never leak between saves.
const docW = this._drawingClampDim(this.drawingDocW, CONFIG.DRAWING_DEFAULT_DOC_W);
const docH = this._drawingClampDim(this.drawingDocH, CONFIG.DRAWING_DEFAULT_DOC_H);
const off = document.createElement('canvas');
off.width = docW;
off.height = docH;
const offCtx = off.getContext('2d');
if (!offCtx) return;
offCtx.fillStyle = CONFIG.DRAWING_BACKGROUND;
offCtx.fillRect(0, 0, docW, docH);
if (this._drawingBaseImage && this._drawingBaseImage.complete) {
offCtx.drawImage(this._drawingBaseImage, 0, 0, docW, docH);
}
for (const op of this.drawingOps) {
this._drawingDrawOp(offCtx, op);
}
// Keep the visible canvas in sync (cosmetic; UI doesn't have to wait for the upload).
this.drawingRedraw(); this.drawingRedraw();
await new Promise((r) => requestAnimationFrame(r));
const blob = await new Promise((resolve, reject) => { const blob = await new Promise((resolve, reject) => {
canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'); off.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png');
}); });
this.isSaving = true; this.isSaving = true;
try { try {

View File

@ -3117,12 +3117,12 @@
> >
<div <div
x-ref="drawingCanvasWrap" x-ref="drawingCanvasWrap"
class="flex-1 w-full min-h-0 min-w-0" class="flex-1 w-full min-h-0 min-w-0 flex items-center justify-center"
style="min-height: 0;" style="min-height: 0; background-color: var(--bg-secondary);"
> >
<canvas <canvas
x-ref="drawingCanvas" x-ref="drawingCanvas"
class="drawing-surface block w-full h-full" class="drawing-surface block"
:style="drawingTool === 'eyedropper' :style="drawingTool === 'eyedropper'
? 'touch-action: none; cursor: copy; background: #fff;' ? 'touch-action: none; cursor: copy; background: #fff;'
: 'touch-action: none; cursor: crosshair; background: #fff;'" : 'touch-action: none; cursor: crosshair; background: #fff;'"