changed to fixed drawing dimensions
This commit is contained in:
parent
239e52dfed
commit
a0e85045d5
184
frontend/app.js
184
frontend/app.js
|
|
@ -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 {
|
||||||
|
|
@ -2847,16 +2872,34 @@ function noteApp() {
|
||||||
_drawingEncodeMediaPath() {
|
_drawingEncodeMediaPath() {
|
||||||
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 doc→display
|
||||||
|
* 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 doc→display 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 {
|
||||||
|
|
|
||||||
|
|
@ -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;'"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue