From cb6725f11c007293c0aabf12aeb7dbe8bfea54a5 Mon Sep 17 00:00:00 2001 From: Gamosoft Date: Tue, 21 Apr 2026 15:22:09 +0200 Subject: [PATCH] added drawing support --- backend/export.py | 2 +- backend/main.py | 52 ++++- backend/utils.py | 7 +- frontend/app.js | 550 ++++++++++++++++++++++++++++++++++++++++---- frontend/index.html | 131 ++++++++++- locales/de-DE.json | 13 ++ locales/en-GB.json | 13 ++ locales/en-US.json | 13 ++ locales/es-ES.json | 13 ++ locales/fr-FR.json | 13 ++ locales/hu-HU.json | 13 ++ locales/it-IT.json | 13 ++ locales/ja-JP.json | 13 ++ locales/ru-RU.json | 13 ++ locales/sl-SI.json | 13 ++ locales/zh-CN.json | 13 ++ 16 files changed, 829 insertions(+), 56 deletions(-) diff --git a/backend/export.py b/backend/export.py index 0a0bd8f..4feb441 100644 --- a/backend/export.py +++ b/backend/export.py @@ -49,7 +49,7 @@ def get_media_as_base64(media_path: Path) -> Optional[Tuple[str, str]]: def get_image_as_base64(image_path: Path) -> Optional[str]: """Read an image file and return it as a base64 data URL.""" result = get_media_as_base64(image_path) - if result and result[1] == 'image': + if result and result[1] in ('image', 'drawing'): return result[0] return None diff --git a/backend/main.py b/backend/main.py index 6499f8b..bbb0b2a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -625,9 +625,59 @@ async def get_media(media_path: str): raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to load media file")) +@api_router.put("/media/{media_path:path}", tags=["Media"]) +@limiter.limit("30/minute") +async def put_media(media_path: str, request: Request): + """ + Overwrite an existing media file in place (used for saving drawing PNGs). + Only files named drawing-*.png are accepted to avoid accidental overwrites. + """ + try: + from backend.utils import ALL_MEDIA_EXTENSIONS + + notes_dir = config['storage']['notes_dir'] + full_path = Path(notes_dir) / media_path + + if not validate_path_security(notes_dir, full_path): + raise HTTPException(status_code=403, detail="Access denied") + + name_lower = full_path.name.lower() + if not (name_lower.startswith('drawing-') and name_lower.endswith('.png')): + raise HTTPException( + status_code=400, + detail="Only drawing PNG files (drawing-*.png) can be updated in place", + ) + + if full_path.suffix.lower() not in ALL_MEDIA_EXTENSIONS: + raise HTTPException(status_code=400, detail="Not an allowed media file") + + if not full_path.exists() or not full_path.is_file(): + raise HTTPException(status_code=404, detail="File not found") + + body = await request.body() + max_size = UPLOAD_MAX_IMAGE_MB * 1024 * 1024 + if len(body) > max_size: + raise HTTPException( + status_code=400, + detail=f"File too large. Maximum size: {UPLOAD_MAX_IMAGE_MB}MB", + ) + + try: + with open(full_path, 'wb') as f: + f.write(body) + except OSError as e: + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to save file")) + + return {"success": True, "path": media_path} + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=safe_error_message(e, "Failed to update media file")) + + @api_router.post("/upload-media", tags=["Media"]) @limiter.limit("20/minute") -async def upload_media(request: Request, file: UploadFile = File(...), note_path: str = Form(...)): +async def upload_media(request: Request, file: UploadFile = File(...), note_path: str = Form("")): """ Upload a media file (image, audio, video, PDF) and save it to the attachments directory. Returns the relative path for markdown linking. diff --git a/backend/utils.py b/backend/utils.py index 26629e0..e639c93 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -671,8 +671,13 @@ ALL_MEDIA_EXTENSIONS = set().union(*MEDIA_EXTENSIONS.values()) def get_media_type(filename: str) -> Optional[str]: """ Determine the media type based on file extension. - Returns: 'image', 'audio', 'video', 'document', or None if not a media file. + Returns: 'image', 'audio', 'video', 'document', 'drawing', or None if not a media file. + + Drawings are PNG files stored like images but named drawing-*.png (editable canvas in the app). """ + name_lower = Path(filename).name.lower() + if name_lower.startswith('drawing-') and name_lower.endswith('.png'): + return 'drawing' ext = Path(filename).suffix.lower() for media_type, extensions in MEDIA_EXTENSIONS.items(): if ext in extensions: diff --git a/frontend/app.js b/frontend/app.js index d171759..b27d0b1 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -494,7 +494,16 @@ function noteApp() { // Media viewer state currentMedia: '', // Path to current media file (kept as 'currentMedia' for compatibility) - currentMediaType: 'image', // 'image', 'audio', 'video', 'document' + currentMediaType: 'image', // 'image', 'audio', 'video', 'document', 'drawing' + + // Drawing canvas (drawing-*.png only) — ops are session-only until Save flattens to PNG + drawingTool: 'freehand', + drawingColor: '#1a1a1a', + drawingLineWidth: 4, + drawingOps: [], + drawingRedoStack: [], + drawingDraft: null, + drawingIsPointerDown: false, // DOM element cache (to avoid repeated querySelector calls) _domCache: { @@ -638,10 +647,15 @@ function noteApp() { window.addEventListener('keydown', (e) => { // Use e.key (not e.code) for letter keys to support non-QWERTY keyboard layouts - // Ctrl/Cmd + S to save + // Ctrl/Cmd + S to save (drawing saves PNG; notes save markdown) if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { - e.preventDefault(); - this.saveNote(); + if (this.currentMedia && this.currentMediaType === 'drawing') { + e.preventDefault(); + this.drawingSave(); + } else { + e.preventDefault(); + this.saveNote(); + } } // Ctrl/Cmd + Alt + P for Quick Switcher @@ -663,21 +677,35 @@ function noteApp() { this.createFolder(); } - // Ctrl/Cmd + Z for undo (without shift or alt) - // Use e.key instead of e.code to support non-QWERTY keyboard layouts + // Ctrl/Cmd + Z for undo (drawing vs note editor) if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') { - e.preventDefault(); - this.undo(); + if (this.currentMedia && this.currentMediaType === 'drawing') { + e.preventDefault(); + this.drawingUndo(); + } else { + e.preventDefault(); + this.undo(); + } } // Ctrl/Cmd + Y OR Ctrl/Cmd+Shift+Z for redo if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') { - e.preventDefault(); - this.redo(); + if (this.currentMedia && this.currentMediaType === 'drawing') { + e.preventDefault(); + this.drawingRedo(); + } else { + e.preventDefault(); + this.redo(); + } } if ((e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') { - e.preventDefault(); - this.redo(); + if (this.currentMedia && this.currentMediaType === 'drawing') { + e.preventDefault(); + this.drawingRedo(); + } else { + e.preventDefault(); + this.redo(); + } } // F3 for next search match @@ -1484,13 +1512,7 @@ function noteApp() { notePath += '.md'; } - // Determine target folder: use dropdown context if set, otherwise homepage folder - let targetFolder; - if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) { - targetFolder = this.dropdownTargetFolder; // Can be '' for root or a folder path - } else { - targetFolder = this.selectedHomepageFolder || ''; - } + const targetFolder = this.inferredNewItemTargetFolder(); // If we have a target folder, create note in that folder if (targetFolder) { @@ -2082,7 +2104,7 @@ function noteApp() { }, handleDeleteItemClick(el, event) { event.stopPropagation(); - if (el.dataset.type === 'image') { + if (el.dataset.type !== 'note') { this.deleteMedia(el.dataset.path); } else { this.deleteNote(el.dataset.path, el.dataset.name); @@ -2208,6 +2230,11 @@ function noteApp() { const isShared = !isMediaFile && this.isNoteShared(note.path); const shareIcon = isShared ? '' : ''; const icon = this.getMediaIcon(note.type); + const deleteTitle = !isMediaFile + ? this.t('toolbar.delete_note') + : note.type === 'drawing' + ? this.t('toolbar.delete_drawing') + : this.t('toolbar.delete_image'); return `
@@ -2487,13 +2514,22 @@ function noteApp() { this.draggedItem = null; }, + /** + * Backend `get_attachment_dir` uses the parent folder of `note_path`. + * With no note open, infer a synthetic path from `currentMedia` so uploads go to the same + * `_attachments` folder as the file being viewed (or vault root when appropriate). + */ + resolveUploadNotePath() { + if (this.currentNote) return this.currentNote; + if (!this.currentMedia) return ''; + const parts = this.currentMedia.split('/').filter(Boolean); + const ai = parts.indexOf('_attachments'); + if (ai === -1 || ai === 0) return ''; + return `${parts.slice(0, ai).join('/')}/_.md`; + }, + // Handle media files dropped into editor async handleMediaDrop(event) { - if (!this.currentNote) { - this.toast(this.t('notes.open_first'), { type: 'info' }); - return; - } - const files = Array.from(event.dataTransfer.files); // Filter for allowed media types @@ -2515,28 +2551,38 @@ function noteApp() { } const textarea = event.target; - // Calculate cursor position from drop coordinates - let cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY); - if (cursorPos < 0) cursorPos = textarea.selectionStart || 0; + const notePath = this.resolveUploadNotePath(); + // Calculate cursor position from drop coordinates (only meaningful when a note is open) + let cursorPos = 0; + if (this.currentNote && textarea && textarea.tagName === 'TEXTAREA') { + cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY); + if (cursorPos < 0) cursorPos = textarea.selectionStart || 0; + } - // Upload each media file + let uploaded = false; for (const file of mediaFiles) { try { - const mediaPath = await this.uploadMedia(file, this.currentNote); + const mediaPath = await this.uploadMedia(file, notePath); if (mediaPath) { - await this.insertMediaMarkdown(mediaPath, file.name, cursorPos); + uploaded = true; + if (this.currentNote) { + await this.insertMediaMarkdown(mediaPath, file.name, cursorPos); + } } } catch (error) { ErrorHandler.handle(`upload file ${file.name}`, error); } } + if (uploaded && !this.currentNote) { + await this.loadNotes(); + } }, // Upload a media file (image, audio, video, PDF) async uploadMedia(file, notePath) { const formData = new FormData(); formData.append('file', file); - formData.append('note_path', notePath); + formData.append('note_path', notePath || ''); try { const response = await fetch('/api/upload-media', { @@ -2621,9 +2667,13 @@ function noteApp() { } }, - // Media type detection based on file extension + // Media type detection based on file extension (and drawing-*.png convention) getMediaType(filename) { if (!filename) return null; + const base = filename.split('/').pop().toLowerCase(); + if (base.startsWith('drawing-') && base.endsWith('.png')) { + return 'drawing'; + } const ext = filename.split('.').pop().toLowerCase(); const mediaTypes = { image: ['jpg', 'jpeg', 'png', 'gif', 'webp'], @@ -2641,6 +2691,7 @@ function noteApp() { getMediaIcon(type) { const icons = { image: '🖼️', + drawing: '✏️', audio: '🎵', video: '🎬', document: '📄', @@ -2662,6 +2713,9 @@ function noteApp() { // View a media file (image, audio, video, PDF) in the main pane viewMedia(mediaPath, mediaType = null, updateHistory = true) { + if (this.currentMediaType === 'drawing') { + this._drawingDisconnectResizeObserver(); + } this.showGraph = false; // Ensure graph is closed this.currentNote = ''; this.currentNoteName = ''; @@ -2688,6 +2742,14 @@ function noteApp() { `/${encodedPath}` ); } + + // Drawing: Alpine x-init on the canvas runs only on first mount; switching from one drawing + // to another keeps currentMediaType === 'drawing', so we must reload the PNG here. + if (this.currentMediaType === 'drawing') { + this.$nextTick(() => { + this.initDrawingViewer(); + }); + } }, // Backward compatibility alias @@ -2723,6 +2785,399 @@ function noteApp() { } }, + /** + * 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). + */ + async createNewDrawing() { + const targetFolder = this.inferredNewItemTargetFolder(); + this.closeDropdown(); + const w = 1200; + const h = 675; + const canvas = document.createElement('canvas'); + canvas.width = w; + canvas.height = h; + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, w, h); + let blob; + try { + blob = await new Promise((resolve, reject) => { + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'); + }); + } catch (e) { + ErrorHandler.handle('create drawing', e); + return; + } + const file = new File([blob], 'drawing.png', { type: 'image/png' }); + try { + const notePath = targetFolder ? `${targetFolder}/_.md` : ''; + const path = await this.uploadMedia(file, notePath); + await this.loadNotes(); + this.viewMedia(path, 'drawing'); + } catch (error) { + ErrorHandler.handle('upload drawing', error); + } + }, + + _drawingEncodeMediaPath() { + return this.currentMedia.split('/').map((s) => encodeURIComponent(s)).join('/'); + }, + + _drawingCanvasCoords(event) { + const canvas = this._drawingCanvasEl; + if (!canvas) return { x: 0, y: 0 }; + const rect = canvas.getBoundingClientRect(); + const scaleX = this._drawingCssW / rect.width; + const scaleY = this._drawingCssH / rect.height; + return { + x: (event.clientX - rect.left) * scaleX, + y: (event.clientY - rect.top) * scaleY, + }; + }, + + _drawingDrawOp(ctx, op) { + if (!op) return; + if (op.type === 'stroke') { + const pts = op.points; + if (!pts || pts.length < 2) return; + ctx.save(); + ctx.strokeStyle = op.color; + ctx.lineWidth = op.lineWidth; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.beginPath(); + ctx.moveTo(pts[0][0], pts[0][1]); + for (let i = 1; i < pts.length; i++) { + ctx.lineTo(pts[i][0], pts[i][1]); + } + ctx.stroke(); + ctx.restore(); + return; + } + ctx.save(); + ctx.strokeStyle = op.color; + ctx.lineWidth = op.lineWidth; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + if (op.type === 'line') { + ctx.beginPath(); + ctx.moveTo(op.x1, op.y1); + ctx.lineTo(op.x2, op.y2); + ctx.stroke(); + } else if (op.type === 'rect') { + const nx = op.w < 0 ? op.x + op.w : op.x; + const ny = op.h < 0 ? op.y + op.h : op.y; + ctx.strokeRect(nx, ny, Math.abs(op.w), Math.abs(op.h)); + } else if (op.type === 'ellipse') { + const nx = op.w < 0 ? op.x + op.w : op.x; + const ny = op.h < 0 ? op.y + op.h : op.y; + const rw = Math.abs(op.w) / 2; + const rh = Math.abs(op.h) / 2; + const cx = nx + rw; + const cy = ny + rh; + ctx.beginPath(); + ctx.ellipse(cx, cy, rw, rh, 0, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.restore(); + }, + + drawingRedraw() { + const canvas = this._drawingCanvasEl; + const ctx = this._drawingCtx; + if (!canvas || !ctx) return; + const w = this._drawingCssW; + const h = this._drawingCssH; + const dpr = this._drawingDpr; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, w, h); + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, w, h); + if (this._drawingBaseImage && this._drawingBaseImage.complete) { + ctx.drawImage(this._drawingBaseImage, 0, 0, w, h); + } + for (const op of this.drawingOps) { + this._drawingDrawOp(ctx, op); + } + if (this.drawingDraft) { + this._drawingDrawOp(ctx, this.drawingDraft); + } + }, + + _drawingScheduleRedraw() { + if (this._drawingRaf) return; + this._drawingRaf = requestAnimationFrame(() => { + this._drawingRaf = null; + this.drawingRedraw(); + }); + }, + + _drawingDisconnectResizeObserver() { + if (this._drawingResizeObserver) { + try { + this._drawingResizeObserver.disconnect(); + } catch (_) { + /* ignore */ + } + this._drawingResizeObserver = null; + } + }, + + /** Size canvas to drawingCanvasWrap and redraw (fills available pane). */ + _drawingLayoutCanvas() { + const wrap = this.$refs.drawingCanvasWrap; + const canvas = this.$refs.drawingCanvas; + if (!wrap || !canvas || this.currentMediaType !== 'drawing') return; + let cssW = wrap.clientWidth; + let cssH = wrap.clientHeight; + if (cssW < 32) cssW = Math.max(320, wrap.parentElement ? wrap.parentElement.clientWidth : 320); + if (cssH < 32) { + const col = wrap.closest('.flex-1.flex.flex-col') || wrap.closest('.flex-1'); + const h = col ? col.clientHeight : 0; + cssH = h > 64 ? h : Math.max(240, Math.floor((cssW || 400) * 0.5)); + } + const dpr = window.devicePixelRatio || 1; + canvas.style.width = `${cssW}px`; + canvas.style.height = `${cssH}px`; + canvas.width = Math.round(cssW * dpr); + canvas.height = Math.round(cssH * dpr); + this._drawingCanvasEl = canvas; + this._drawingCssW = cssW; + this._drawingCssH = cssH; + this._drawingDpr = dpr; + if (!this._drawingCtx) { + this._drawingCtx = canvas.getContext('2d'); + } + this._drawingCtx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.drawingRedraw(); + }, + + async initDrawingViewer() { + if (this.currentMediaType !== 'drawing' || !this.currentMedia) return; + this._drawingDisconnectResizeObserver(); + await this.$nextTick(); + if (this._drawingObjectURL) { + URL.revokeObjectURL(this._drawingObjectURL); + this._drawingObjectURL = null; + } + const canvas = this.$refs.drawingCanvas; + const wrap = this.$refs.drawingCanvasWrap; + if (!canvas || !wrap) return; + this._drawingCtx = canvas.getContext('2d'); + this.drawingOps = []; + this.drawingRedoStack = []; + this.drawingDraft = null; + this.drawingIsPointerDown = false; + this._drawingBaseImage = null; + this._drawingLoadToken = Symbol(); + const token = this._drawingLoadToken; + + this._drawingResizeObserver = new ResizeObserver(() => { + if (this.currentMediaType !== 'drawing' || !this.currentMedia) return; + this._drawingLayoutCanvas(); + }); + this._drawingResizeObserver.observe(wrap); + + requestAnimationFrame(() => { + if (token !== this._drawingLoadToken) return; + this._drawingLayoutCanvas(); + requestAnimationFrame(() => { + if (token !== this._drawingLoadToken) return; + this._drawingLayoutCanvas(); + }); + }); + + try { + const enc = this._drawingEncodeMediaPath(); + const res = await fetch(`/api/media/${enc}`, { credentials: 'same-origin' }); + if (!res.ok) throw new Error('Failed to load drawing'); + const blob = await res.blob(); + if (token !== this._drawingLoadToken) return; + const url = URL.createObjectURL(blob); + this._drawingObjectURL = url; + const img = new Image(); + await new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + img.src = url; + }); + if (token !== this._drawingLoadToken) return; + this._drawingBaseImage = img; + this._drawingLayoutCanvas(); + } catch (e) { + if (token !== this._drawingLoadToken) return; + ErrorHandler.handle('load drawing', e); + this._drawingLayoutCanvas(); + } + }, + + drawingPointerDown(e) { + if (this.currentMediaType !== 'drawing' || e.button !== 0) return; + const canvas = this._drawingCanvasEl; + if (!canvas) return; + canvas.setPointerCapture(e.pointerId); + this._drawingPointerId = e.pointerId; + const { x, y } = this._drawingCanvasCoords(e); + this.drawingIsPointerDown = true; + this.drawingRedoStack = []; + const color = this.drawingColor; + const lw = this.drawingLineWidth; + const tool = this.drawingTool; + if (tool === 'freehand') { + this.drawingDraft = { type: 'stroke', color, lineWidth: lw, points: [[x, y]] }; + } else if (tool === 'line') { + this.drawingDraft = { type: 'line', color, lineWidth: lw, x1: x, y1: y, x2: x, y2: y }; + } else if (tool === 'rect') { + this.drawingDraft = { type: 'rect', color, lineWidth: lw, x, y, w: 0, h: 0 }; + } else if (tool === 'ellipse') { + this.drawingDraft = { type: 'ellipse', color, lineWidth: lw, x, y, w: 0, h: 0 }; + } + this.drawingRedraw(); + }, + + drawingPointerMove(e) { + if (!this.drawingIsPointerDown || this.currentMediaType !== 'drawing') return; + const { x, y } = this._drawingCanvasCoords(e); + const d = this.drawingDraft; + if (!d) return; + if (d.type === 'stroke') { + const pts = d.points; + const last = pts[pts.length - 1]; + const dx = x - last[0]; + const dy = y - last[1]; + if (dx * dx + dy * dy < 1) return; + pts.push([x, y]); + this._drawingScheduleRedraw(); + return; + } + if (d.type === 'line') { + d.x2 = x; + d.y2 = y; + } else if (d.type === 'rect' || d.type === 'ellipse') { + d.w = x - d.x; + d.h = y - d.y; + } + this._drawingScheduleRedraw(); + }, + + drawingPointerUp(e) { + if (!this.drawingIsPointerDown || this.currentMediaType !== 'drawing') return; + const canvas = this._drawingCanvasEl; + if (canvas && this._drawingPointerId === e.pointerId) { + try { + canvas.releasePointerCapture(e.pointerId); + } catch (_) { + /* ignore */ + } + } + this._drawingPointerId = null; + this.drawingIsPointerDown = false; + const d = this.drawingDraft; + this.drawingDraft = null; + if (!d) { + this.drawingRedraw(); + return; + } + if (d.type === 'stroke') { + if (!d.points || d.points.length < 2) { + this.drawingRedraw(); + return; + } + this.drawingOps.push({ + type: 'stroke', + color: d.color, + lineWidth: d.lineWidth, + points: d.points.slice(), + }); + } else if (d.type === 'line') { + const dx = d.x2 - d.x1; + const dy = d.y2 - d.y1; + if (dx * dx + dy * dy < 4) { + this.drawingRedraw(); + return; + } + this.drawingOps.push({ + type: 'line', + color: d.color, + lineWidth: d.lineWidth, + x1: d.x1, + y1: d.y1, + x2: d.x2, + y2: d.y2, + }); + } else if (d.type === 'rect' || d.type === 'ellipse') { + if (Math.abs(d.w) < 2 && Math.abs(d.h) < 2) { + this.drawingRedraw(); + return; + } + this.drawingOps.push({ + type: d.type, + color: d.color, + lineWidth: d.lineWidth, + x: d.x, + y: d.y, + w: d.w, + h: d.h, + }); + } + this.drawingRedraw(); + }, + + drawingUndo() { + if (this.drawingOps.length === 0) return; + this.drawingRedoStack.push(this.drawingOps.pop()); + this.drawingRedraw(); + }, + + drawingRedo() { + if (this.drawingRedoStack.length === 0) return; + this.drawingOps.push(this.drawingRedoStack.pop()); + this.drawingRedraw(); + }, + + async drawingSave() { + if (!this.currentMedia || this.currentMediaType !== 'drawing') return; + const canvas = this._drawingCanvasEl; + const ctx = this._drawingCtx; + if (!canvas || !ctx) return; + this.drawingDraft = null; + this.drawingIsPointerDown = false; + this.drawingRedraw(); + await new Promise((r) => requestAnimationFrame(r)); + const blob = await new Promise((resolve, reject) => { + canvas.toBlob((b) => (b ? resolve(b) : reject(new Error('toBlob failed'))), 'image/png'); + }); + try { + const enc = this._drawingEncodeMediaPath(); + const res = await fetch(`/api/media/${enc}`, { + method: 'PUT', + body: blob, + headers: { 'Content-Type': 'image/png' }, + credentials: 'same-origin', + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + let detail = err.detail; + if (Array.isArray(detail)) { + detail = detail.map((d) => d.msg || d).join(', '); + } + throw new Error(detail || res.statusText); + } + await this.loadNotes(); + if (this._drawingObjectURL) { + URL.revokeObjectURL(this._drawingObjectURL); + this._drawingObjectURL = null; + } + this.drawingOps = []; + this.drawingRedoStack = []; + await this.initDrawingViewer(); + this.toast(this.t('drawing.saved'), { type: 'success' }); + } catch (error) { + ErrorHandler.handle('save drawing', error); + } + }, + // Handle clicks on internal links in preview handleInternalLink(event) { // Check if clicked element is a link @@ -2981,6 +3436,9 @@ function noteApp() { window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/'); this.currentNote = ''; this.noteContent = ''; + if (this.currentMediaType === 'drawing') { + this._drawingDisconnectResizeObserver(); + } this.currentMedia = ''; document.title = this.appName; return; @@ -2997,6 +3455,9 @@ function noteApp() { this._initializedVideoSources = new Set(); // Clear video cache for new note this.noteContent = data.content; this.currentNoteName = notePath.split('/').pop().replace('.md', ''); + if (this.currentMediaType === 'drawing') { + this._drawingDisconnectResizeObserver(); + } this.currentMedia = ''; // Clear image viewer when loading a note this.shareInfo = null; // Reset share info for new note @@ -3353,6 +3814,17 @@ function noteApp() { this.dropdownTargetFolder = null; // Reset folder context }, + /** + * Parent folder for new note/folder/drawing from the + menu when no explicit path is passed. + * Same rules as the create-name modal: '' = vault root; otherwise a folder path (e.g. folder1/sub). + */ + inferredNewItemTargetFolder() { + if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) { + return this.dropdownTargetFolder; + } + return this.selectedHomepageFolder || ''; + }, + // ===================================================== // UNIFIED CREATION FUNCTIONS (reusable from anywhere) // ===================================================== @@ -3391,14 +3863,8 @@ function noteApp() { * @param {string|undefined} explicitTargetFolder - if set, use as parent folder context ("" = root) */ openCreateNameModal(kind, explicitTargetFolder = undefined) { - let targetFolder; - if (explicitTargetFolder !== undefined) { - targetFolder = explicitTargetFolder; - } else if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) { - targetFolder = this.dropdownTargetFolder; - } else { - targetFolder = this.selectedHomepageFolder || ''; - } + const targetFolder = + explicitTargetFolder !== undefined ? explicitTargetFolder : this.inferredNewItemTargetFolder(); this.closeDropdown(); this.mobileSidebarOpen = false; this.createNameModalKind = kind; diff --git a/frontend/index.html b/frontend/index.html index 71924ef..4cbb8b9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2408,7 +2408,7 @@ style="position: absolute; top: 8px; right: 8px; opacity: 0; transition: opacity 0.2s; color: var(--error); padding: 4px; border-radius: 4px; background-color: var(--bg-secondary);" onmouseover="this.style.backgroundColor='var(--bg-hover)'" onmouseout="this.style.backgroundColor='var(--bg-secondary)'" - :title="note.type !== 'note' ? t('toolbar.delete_image') : t('toolbar.delete_note')" + :title="note.type === 'note' ? t('toolbar.delete_note') : (note.type === 'drawing' ? t('toolbar.delete_drawing') : t('toolbar.delete_image'))" > @@ -2467,12 +2467,12 @@ class="text-xl font-semibold border-none focus:outline-none focus:ring-2 rounded px-3 py-1.5" :style="'background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border-primary); min-width: 150px; max-width: 300px;' + (!currentNote ? ' cursor: default; opacity: 0.9;' : '')" > - +
+ +
+ + + + +
+ + + +
+
@@ -2875,8 +2949,9 @@
@@ -3115,6 +3214,16 @@ 📄 + diff --git a/locales/de-DE.json b/locales/de-DE.json index b2310bc..974befa 100644 --- a/locales/de-DE.json +++ b/locales/de-DE.json @@ -42,6 +42,7 @@ "new_note": "Neue Notiz", "new_folder": "Neuer Ordner", "new_from_template": "Neu aus Vorlage", + "new_drawing": "Neue Zeichnung", "rename_folder": "Ordner umbenennen", "delete_folder": "Ordner löschen", "search_placeholder": "Notizen durchsuchen...", @@ -124,6 +125,7 @@ "redo": "Wiederholen (Strg+Y)", "delete_note": "Notiz löschen", "delete_image": "Bild löschen", + "delete_drawing": "Zeichnung löschen", "export_html": "Als HTML exportieren", "print_preview": "Druckvorschau", "copy_link": "Link in Zwischenablage kopieren", @@ -226,6 +228,17 @@ "error_incorrect_password": "Falsches Passwort. Bitte versuche es erneut." }, + "drawing": { + "tool_freehand": "Stift — Freihand", + "tool_line": "Gerade Linie", + "tool_rect": "Rechteck", + "tool_ellipse": "Kreis", + "color": "Farbe", + "width": "Strichstärke", + "saved": "Zeichnung gespeichert", + "save_hint": "Zeichnung speichern (Strg+S)" + }, + "media": { "confirm_delete": "\"{{name}}\" löschen?", "upload_failed": "Datei-Upload fehlgeschlagen", diff --git a/locales/en-GB.json b/locales/en-GB.json index 52054f6..2ddaf96 100644 --- a/locales/en-GB.json +++ b/locales/en-GB.json @@ -42,6 +42,7 @@ "new_note": "New Note", "new_folder": "New Folder", "new_from_template": "New from Template", + "new_drawing": "New Drawing", "rename_folder": "Rename folder", "delete_folder": "Delete folder", "search_placeholder": "Search notes...", @@ -123,6 +124,7 @@ "redo": "Redo (Ctrl+Y)", "delete_note": "Delete note", "delete_image": "Delete image", + "delete_drawing": "Delete drawing", "export_html": "Export as HTML", "print_preview": "Print preview", "copy_link": "Copy link to clipboard", @@ -225,6 +227,17 @@ "error_incorrect_password": "Incorrect password. Please try again." }, + "drawing": { + "tool_freehand": "Pencil — freehand drawing", + "tool_line": "Straight line", + "tool_rect": "Rectangle", + "tool_ellipse": "Circle", + "color": "Colour", + "width": "Stroke width", + "saved": "Drawing saved", + "save_hint": "Save drawing (Ctrl+S)" + }, + "media": { "confirm_delete": "Delete \"{{name}}\"?", "upload_failed": "Failed to upload file", diff --git a/locales/en-US.json b/locales/en-US.json index af4614b..92fd810 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -42,6 +42,7 @@ "new_note": "New Note", "new_folder": "New Folder", "new_from_template": "New from Template", + "new_drawing": "New Drawing", "rename_folder": "Rename folder", "delete_folder": "Delete folder", "search_placeholder": "Search notes...", @@ -124,6 +125,7 @@ "redo": "Redo (Ctrl+Y)", "delete_note": "Delete note", "delete_image": "Delete image", + "delete_drawing": "Delete drawing", "export_html": "Export as HTML", "print_preview": "Print preview", "copy_link": "Copy link to clipboard", @@ -226,6 +228,17 @@ "error_incorrect_password": "Incorrect password. Please try again." }, + "drawing": { + "tool_freehand": "Pencil — freehand drawing", + "tool_line": "Straight line", + "tool_rect": "Rectangle", + "tool_ellipse": "Circle", + "color": "Color", + "width": "Stroke width", + "saved": "Drawing saved", + "save_hint": "Save drawing (Ctrl+S)" + }, + "media": { "confirm_delete": "Delete \"{{name}}\"?", "upload_failed": "Failed to upload file", diff --git a/locales/es-ES.json b/locales/es-ES.json index a4a9f2e..199cf26 100644 --- a/locales/es-ES.json +++ b/locales/es-ES.json @@ -42,6 +42,7 @@ "new_note": "Nueva Nota", "new_folder": "Nueva Carpeta", "new_from_template": "Nueva desde Plantilla", + "new_drawing": "Nuevo dibujo", "rename_folder": "Renombrar carpeta", "delete_folder": "Eliminar carpeta", "search_placeholder": "Buscar notas...", @@ -124,6 +125,7 @@ "redo": "Rehacer (Ctrl+Y)", "delete_note": "Eliminar nota", "delete_image": "Eliminar imagen", + "delete_drawing": "Eliminar dibujo", "export_html": "Exportar como HTML", "print_preview": "Vista previa de impresión", "copy_link": "Copiar enlace al portapapeles", @@ -226,6 +228,17 @@ "error_incorrect_password": "Contraseña incorrecta. Por favor, inténtalo de nuevo." }, + "drawing": { + "tool_freehand": "Lápiz — trazo libre", + "tool_line": "Línea recta", + "tool_rect": "Rectángulo", + "tool_ellipse": "Círculo", + "color": "Color", + "width": "Grosor del trazo", + "saved": "Dibujo guardado", + "save_hint": "Guardar dibujo (Ctrl+S)" + }, + "media": { "confirm_delete": "¿Eliminar \"{{name}}\"?", "upload_failed": "Error al subir archivo", diff --git a/locales/fr-FR.json b/locales/fr-FR.json index 5f3e08d..0de70c2 100644 --- a/locales/fr-FR.json +++ b/locales/fr-FR.json @@ -42,6 +42,7 @@ "new_note": "Nouvelle Note", "new_folder": "Nouveau Dossier", "new_from_template": "Nouveau depuis Modèle", + "new_drawing": "Nouveau dessin", "rename_folder": "Renommer le dossier", "delete_folder": "Supprimer le dossier", "search_placeholder": "Rechercher des notes...", @@ -124,6 +125,7 @@ "redo": "Rétablir (Ctrl+Y)", "delete_note": "Supprimer la note", "delete_image": "Supprimer l'image", + "delete_drawing": "Supprimer le dessin", "export_html": "Exporter en HTML", "print_preview": "Aperçu avant impression", "copy_link": "Copier le lien dans le presse-papiers", @@ -226,6 +228,17 @@ "error_incorrect_password": "Mot de passe incorrect. Veuillez réessayer." }, + "drawing": { + "tool_freehand": "Crayon — main levée", + "tool_line": "Ligne droite", + "tool_rect": "Rectangle", + "tool_ellipse": "Cercle", + "color": "Couleur", + "width": "Épaisseur du trait", + "saved": "Dessin enregistré", + "save_hint": "Enregistrer le dessin (Ctrl+S)" + }, + "media": { "confirm_delete": "Supprimer \"{{name}}\" ?", "upload_failed": "Échec du téléchargement du fichier", diff --git a/locales/hu-HU.json b/locales/hu-HU.json index 298d945..63aa67c 100644 --- a/locales/hu-HU.json +++ b/locales/hu-HU.json @@ -42,6 +42,7 @@ "new_note": "Új Jegyzet", "new_folder": "Új Mappa", "new_from_template": "Új Sablonból", + "new_drawing": "Új rajz", "rename_folder": "Mappa átnevezése", "delete_folder": "Mappa törlése", "search_placeholder": "Jegyzetek keresése...", @@ -124,6 +125,7 @@ "redo": "Helyrehoz (Ctrl+Y)", "delete_note": "Jegyzet törlése", "delete_image": "Kép törlése", + "delete_drawing": "Rajz törlése", "export_html": "Exportálás HTML-ként", "print_preview": "Nyomtatási előnézet", "copy_link": "Link másolása", @@ -226,6 +228,17 @@ "error_incorrect_password": "Helytelen jelszó. Próbáld újra." }, + "drawing": { + "tool_freehand": "Ceruza — szabadkézi", + "tool_line": "Egyenes vonal", + "tool_rect": "Téglalap", + "tool_ellipse": "Kör", + "color": "Szín", + "width": "Vonalvastagság", + "saved": "Rajz elmentve", + "save_hint": "Rajz mentése (Ctrl+S)" + }, + "media": { "confirm_delete": "Biztos törlöd a következőt: \"{{name}}\"?", "upload_failed": "Hiba a fájl feltöltése során", diff --git a/locales/it-IT.json b/locales/it-IT.json index c6c6e43..c216a2b 100644 --- a/locales/it-IT.json +++ b/locales/it-IT.json @@ -42,6 +42,7 @@ "new_note": "Nuova Nota", "new_folder": "Nuova Cartella", "new_from_template": "Nuovo da Modello", + "new_drawing": "Nuovo disegno", "rename_folder": "Rinomina cartella", "delete_folder": "Elimina cartella", "search_placeholder": "Cerca note...", @@ -123,6 +124,7 @@ "redo": "Ripeti (Ctrl+Y)", "delete_note": "Elimina nota", "delete_image": "Elimina immagine", + "delete_drawing": "Elimina disegno", "export_html": "Esporta come HTML", "print_preview": "Anteprima di stampa", "copy_link": "Copia link negli appunti", @@ -225,6 +227,17 @@ "error_incorrect_password": "Password errata. Riprova." }, + "drawing": { + "tool_freehand": "Matita — mano libera", + "tool_line": "Linea retta", + "tool_rect": "Rettangolo", + "tool_ellipse": "Cerchio", + "color": "Colore", + "width": "Spessore tratto", + "saved": "Disegno salvato", + "save_hint": "Salva disegno (Ctrl+S)" + }, + "media": { "confirm_delete": "Eliminare \"{{name}}\"?", "upload_failed": "Caricamento file fallito", diff --git a/locales/ja-JP.json b/locales/ja-JP.json index 93067c7..2932ab2 100644 --- a/locales/ja-JP.json +++ b/locales/ja-JP.json @@ -42,6 +42,7 @@ "new_note": "新規ノート", "new_folder": "新規フォルダ", "new_from_template": "テンプレートから作成", + "new_drawing": "新規お絵かき", "rename_folder": "フォルダの名前を変更", "delete_folder": "フォルダを削除", "search_placeholder": "ノートを検索...", @@ -123,6 +124,7 @@ "redo": "やり直す (Ctrl+Y)", "delete_note": "ノートを削除", "delete_image": "画像を削除", + "delete_drawing": "お絵かきを削除", "export_html": "HTMLとしてエクスポート", "print_preview": "印刷プレビュー", "copy_link": "リンクをクリップボードにコピー", @@ -225,6 +227,17 @@ "error_incorrect_password": "パスワードが正しくありません。もう一度お試しください。" }, + "drawing": { + "tool_freehand": "鉛筆(フリーハンド)", + "tool_line": "直線", + "tool_rect": "長方形", + "tool_ellipse": "円", + "color": "色", + "width": "線の太さ", + "saved": "保存しました", + "save_hint": "お絵かきを保存(Ctrl+S)" + }, + "media": { "confirm_delete": "「{{name}}」を削除しますか?", "upload_failed": "ファイルのアップロードに失敗しました", diff --git a/locales/ru-RU.json b/locales/ru-RU.json index 14690b1..dd1685e 100644 --- a/locales/ru-RU.json +++ b/locales/ru-RU.json @@ -42,6 +42,7 @@ "new_note": "Новая заметка", "new_folder": "Новая папка", "new_from_template": "Из шаблона", + "new_drawing": "Новый рисунок", "rename_folder": "Переименовать папку", "delete_folder": "Удалить папку", "search_placeholder": "Поиск заметок...", @@ -123,6 +124,7 @@ "redo": "Повторить (Ctrl+Y)", "delete_note": "Удалить заметку", "delete_image": "Удалить изображение", + "delete_drawing": "Удалить рисунок", "export_html": "Экспорт в HTML", "print_preview": "Предварительный просмотр печати", "copy_link": "Копировать ссылку", @@ -225,6 +227,17 @@ "error_incorrect_password": "Неверный пароль. Попробуйте снова." }, + "drawing": { + "tool_freehand": "Карандаш — от руки", + "tool_line": "Прямая линия", + "tool_rect": "Прямоугольник", + "tool_ellipse": "Круг", + "color": "Цвет", + "width": "Толщина линии", + "saved": "Рисунок сохранён", + "save_hint": "Сохранить рисунок (Ctrl+S)" + }, + "media": { "confirm_delete": "Удалить «{{name}}»?", "upload_failed": "Не удалось загрузить файл", diff --git a/locales/sl-SI.json b/locales/sl-SI.json index b80d9b7..b219d6b 100644 --- a/locales/sl-SI.json +++ b/locales/sl-SI.json @@ -42,6 +42,7 @@ "new_note": "Nov zapis", "new_folder": "Nova mapa", "new_from_template": "Novo iz predloge", + "new_drawing": "Nov risba", "rename_folder": "Preimenuj mapo", "delete_folder": "Izbriši mapo", "search_placeholder": "Išči zapiske...", @@ -123,6 +124,7 @@ "redo": "Ponovi (Ctrl+Y)", "delete_note": "Izbriši zapis", "delete_image": "Izbriši sliko", + "delete_drawing": "Izbriši risbo", "export_html": "Izvozi kot HTML", "print_preview": "Predogled tiskanja", "copy_link": "Kopiraj povezavo v odložišče", @@ -225,6 +227,17 @@ "error_incorrect_password": "Napačno geslo. Prosimo, poskusite znova." }, + "drawing": { + "tool_freehand": "Svinčnik — prostoročno", + "tool_line": "Ravna črta", + "tool_rect": "Pravokotnik", + "tool_ellipse": "Krog", + "color": "Barva", + "width": "Debelina črte", + "saved": "Risba shranjena", + "save_hint": "Shrani risbo (Ctrl+S)" + }, + "media": { "confirm_delete": "Izbrišem \"{{name}}\"?", "upload_failed": "Nalaganje datoteke ni uspelo", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 981cc76..0d603f0 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -42,6 +42,7 @@ "new_note": "新建笔记", "new_folder": "新建文件夹", "new_from_template": "从模板新建", + "new_drawing": "新建绘图", "rename_folder": "重命名文件夹", "delete_folder": "删除文件夹", "search_placeholder": "搜索笔记...", @@ -123,6 +124,7 @@ "redo": "重做 (Ctrl+Y)", "delete_note": "删除笔记", "delete_image": "删除图片", + "delete_drawing": "删除绘图", "export_html": "导出为 HTML", "print_preview": "打印预览", "copy_link": "复制链接到剪贴板", @@ -225,6 +227,17 @@ "error_incorrect_password": "密码错误。请重试。" }, + "drawing": { + "tool_freehand": "铅笔 — 自由绘制", + "tool_line": "直线", + "tool_rect": "矩形", + "tool_ellipse": "圆形", + "color": "颜色", + "width": "线条粗细", + "saved": "绘图已保存", + "save_hint": "保存绘图(Ctrl+S)" + }, + "media": { "confirm_delete": "删除 \"{{name}}\"?", "upload_failed": "上传文件失败",