added drawing support

This commit is contained in:
Gamosoft 2026-04-21 15:22:09 +02:00
parent b2437a68be
commit cb6725f11c
16 changed files with 829 additions and 56 deletions

View File

@ -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]: def get_image_as_base64(image_path: Path) -> Optional[str]:
"""Read an image file and return it as a base64 data URL.""" """Read an image file and return it as a base64 data URL."""
result = get_media_as_base64(image_path) 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 result[0]
return None return None

View File

@ -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")) 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"]) @api_router.post("/upload-media", tags=["Media"])
@limiter.limit("20/minute") @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. Upload a media file (image, audio, video, PDF) and save it to the attachments directory.
Returns the relative path for markdown linking. Returns the relative path for markdown linking.

View File

@ -671,8 +671,13 @@ ALL_MEDIA_EXTENSIONS = set().union(*MEDIA_EXTENSIONS.values())
def get_media_type(filename: str) -> Optional[str]: def get_media_type(filename: str) -> Optional[str]:
""" """
Determine the media type based on file extension. 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() ext = Path(filename).suffix.lower()
for media_type, extensions in MEDIA_EXTENSIONS.items(): for media_type, extensions in MEDIA_EXTENSIONS.items():
if ext in extensions: if ext in extensions:

View File

@ -494,7 +494,16 @@ function noteApp() {
// Media viewer state // Media viewer state
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' 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) // DOM element cache (to avoid repeated querySelector calls)
_domCache: { _domCache: {
@ -638,11 +647,16 @@ function noteApp() {
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
// Use e.key (not e.code) for letter keys to support non-QWERTY keyboard layouts // 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') { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
if (this.currentMedia && this.currentMediaType === 'drawing') {
e.preventDefault();
this.drawingSave();
} else {
e.preventDefault(); e.preventDefault();
this.saveNote(); this.saveNote();
} }
}
// Ctrl/Cmd + Alt + P for Quick Switcher // Ctrl/Cmd + Alt + P for Quick Switcher
if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'p') { if ((e.ctrlKey || e.metaKey) && e.altKey && e.key.toLowerCase() === 'p') {
@ -663,22 +677,36 @@ function noteApp() {
this.createFolder(); this.createFolder();
} }
// Ctrl/Cmd + Z for undo (without shift or alt) // Ctrl/Cmd + Z for undo (drawing vs note editor)
// Use e.key instead of e.code to support non-QWERTY keyboard layouts
if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') { if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') {
if (this.currentMedia && this.currentMediaType === 'drawing') {
e.preventDefault();
this.drawingUndo();
} else {
e.preventDefault(); e.preventDefault();
this.undo(); this.undo();
} }
}
// Ctrl/Cmd + Y OR Ctrl/Cmd+Shift+Z for redo // Ctrl/Cmd + Y OR Ctrl/Cmd+Shift+Z for redo
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') { if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
if (this.currentMedia && this.currentMediaType === 'drawing') {
e.preventDefault();
this.drawingRedo();
} else {
e.preventDefault(); e.preventDefault();
this.redo(); this.redo();
} }
}
if ((e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') { if ((e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey && e.key.toLowerCase() === 'z') {
if (this.currentMedia && this.currentMediaType === 'drawing') {
e.preventDefault();
this.drawingRedo();
} else {
e.preventDefault(); e.preventDefault();
this.redo(); this.redo();
} }
}
// F3 for next search match // F3 for next search match
if (e.code === 'F3' && !e.shiftKey) { if (e.code === 'F3' && !e.shiftKey) {
@ -1484,13 +1512,7 @@ function noteApp() {
notePath += '.md'; notePath += '.md';
} }
// Determine target folder: use dropdown context if set, otherwise homepage folder const targetFolder = this.inferredNewItemTargetFolder();
let targetFolder;
if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) {
targetFolder = this.dropdownTargetFolder; // Can be '' for root or a folder path
} else {
targetFolder = this.selectedHomepageFolder || '';
}
// If we have a target folder, create note in that folder // If we have a target folder, create note in that folder
if (targetFolder) { if (targetFolder) {
@ -2082,7 +2104,7 @@ function noteApp() {
}, },
handleDeleteItemClick(el, event) { handleDeleteItemClick(el, event) {
event.stopPropagation(); event.stopPropagation();
if (el.dataset.type === 'image') { if (el.dataset.type !== 'note') {
this.deleteMedia(el.dataset.path); this.deleteMedia(el.dataset.path);
} else { } else {
this.deleteNote(el.dataset.path, el.dataset.name); this.deleteNote(el.dataset.path, el.dataset.name);
@ -2208,6 +2230,11 @@ function noteApp() {
const isShared = !isMediaFile && this.isNoteShared(note.path); const isShared = !isMediaFile && this.isNoteShared(note.path);
const shareIcon = isShared ? '<svg aria-hidden="true" style="display: inline-block; width: 12px; height: 12px; vertical-align: middle; margin-right: 2px; opacity: 0.7;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"></path></svg>' : ''; const shareIcon = isShared ? '<svg aria-hidden="true" style="display: inline-block; width: 12px; height: 12px; vertical-align: middle; margin-right: 2px; opacity: 0.7;" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z"></path></svg>' : '';
const icon = this.getMediaIcon(note.type); 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 ` return `
<div <div
@ -2231,7 +2258,7 @@ function noteApp() {
onclick="window.$root.handleDeleteItemClick(this, event)" onclick="window.$root.handleDeleteItemClick(this, event)"
class="note-delete-btn absolute right-2 top-1/2 transform -translate-y-1/2 px-1 py-0.5 text-xs rounded hover:brightness-110 transition-opacity" class="note-delete-btn absolute right-2 top-1/2 transform -translate-y-1/2 px-1 py-0.5 text-xs rounded hover:brightness-110 transition-opacity"
style="opacity: 0; color: var(--error);" style="opacity: 0; color: var(--error);"
title="${esc(isMediaFile ? this.t('toolbar.delete_image') : this.t('toolbar.delete_note'))}" title="${esc(deleteTitle)}"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
@ -2487,13 +2514,22 @@ function noteApp() {
this.draggedItem = null; 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 // Handle media files dropped into editor
async handleMediaDrop(event) { async handleMediaDrop(event) {
if (!this.currentNote) {
this.toast(this.t('notes.open_first'), { type: 'info' });
return;
}
const files = Array.from(event.dataTransfer.files); const files = Array.from(event.dataTransfer.files);
// Filter for allowed media types // Filter for allowed media types
@ -2515,28 +2551,38 @@ function noteApp() {
} }
const textarea = event.target; const textarea = event.target;
// Calculate cursor position from drop coordinates const notePath = this.resolveUploadNotePath();
let cursorPos = this.getTextareaCursorFromPoint(textarea, event.clientX, event.clientY); // 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; if (cursorPos < 0) cursorPos = textarea.selectionStart || 0;
}
// Upload each media file let uploaded = false;
for (const file of mediaFiles) { for (const file of mediaFiles) {
try { try {
const mediaPath = await this.uploadMedia(file, this.currentNote); const mediaPath = await this.uploadMedia(file, notePath);
if (mediaPath) { if (mediaPath) {
uploaded = true;
if (this.currentNote) {
await this.insertMediaMarkdown(mediaPath, file.name, cursorPos); await this.insertMediaMarkdown(mediaPath, file.name, cursorPos);
} }
}
} catch (error) { } catch (error) {
ErrorHandler.handle(`upload file ${file.name}`, error); ErrorHandler.handle(`upload file ${file.name}`, error);
} }
} }
if (uploaded && !this.currentNote) {
await this.loadNotes();
}
}, },
// Upload a media file (image, audio, video, PDF) // Upload a media file (image, audio, video, PDF)
async uploadMedia(file, notePath) { async uploadMedia(file, notePath) {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
formData.append('note_path', notePath); formData.append('note_path', notePath || '');
try { try {
const response = await fetch('/api/upload-media', { 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) { getMediaType(filename) {
if (!filename) return null; 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 ext = filename.split('.').pop().toLowerCase();
const mediaTypes = { const mediaTypes = {
image: ['jpg', 'jpeg', 'png', 'gif', 'webp'], image: ['jpg', 'jpeg', 'png', 'gif', 'webp'],
@ -2641,6 +2691,7 @@ function noteApp() {
getMediaIcon(type) { getMediaIcon(type) {
const icons = { const icons = {
image: '🖼️', image: '🖼️',
drawing: '✏️',
audio: '🎵', audio: '🎵',
video: '🎬', video: '🎬',
document: '📄', document: '📄',
@ -2662,6 +2713,9 @@ function noteApp() {
// View a media file (image, audio, video, PDF) in the main pane // View a media file (image, audio, video, PDF) in the main pane
viewMedia(mediaPath, mediaType = null, updateHistory = true) { viewMedia(mediaPath, mediaType = null, updateHistory = true) {
if (this.currentMediaType === 'drawing') {
this._drawingDisconnectResizeObserver();
}
this.showGraph = false; // Ensure graph is closed this.showGraph = false; // Ensure graph is closed
this.currentNote = ''; this.currentNote = '';
this.currentNoteName = ''; this.currentNoteName = '';
@ -2688,6 +2742,14 @@ function noteApp() {
`/${encodedPath}` `/${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 // 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 // Handle clicks on internal links in preview
handleInternalLink(event) { handleInternalLink(event) {
// Check if clicked element is a link // Check if clicked element is a link
@ -2981,6 +3436,9 @@ function noteApp() {
window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/'); window.history.replaceState({ homepageFolder: this.selectedHomepageFolder || '' }, '', '/');
this.currentNote = ''; this.currentNote = '';
this.noteContent = ''; this.noteContent = '';
if (this.currentMediaType === 'drawing') {
this._drawingDisconnectResizeObserver();
}
this.currentMedia = ''; this.currentMedia = '';
document.title = this.appName; document.title = this.appName;
return; return;
@ -2997,6 +3455,9 @@ function noteApp() {
this._initializedVideoSources = new Set(); // Clear video cache for new note this._initializedVideoSources = new Set(); // Clear video cache for new note
this.noteContent = data.content; this.noteContent = data.content;
this.currentNoteName = notePath.split('/').pop().replace('.md', ''); this.currentNoteName = notePath.split('/').pop().replace('.md', '');
if (this.currentMediaType === 'drawing') {
this._drawingDisconnectResizeObserver();
}
this.currentMedia = ''; // Clear image viewer when loading a note this.currentMedia = ''; // Clear image viewer when loading a note
this.shareInfo = null; // Reset share info for new note this.shareInfo = null; // Reset share info for new note
@ -3353,6 +3814,17 @@ function noteApp() {
this.dropdownTargetFolder = null; // Reset folder context 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) // UNIFIED CREATION FUNCTIONS (reusable from anywhere)
// ===================================================== // =====================================================
@ -3391,14 +3863,8 @@ function noteApp() {
* @param {string|undefined} explicitTargetFolder - if set, use as parent folder context ("" = root) * @param {string|undefined} explicitTargetFolder - if set, use as parent folder context ("" = root)
*/ */
openCreateNameModal(kind, explicitTargetFolder = undefined) { openCreateNameModal(kind, explicitTargetFolder = undefined) {
let targetFolder; const targetFolder =
if (explicitTargetFolder !== undefined) { explicitTargetFolder !== undefined ? explicitTargetFolder : this.inferredNewItemTargetFolder();
targetFolder = explicitTargetFolder;
} else if (this.dropdownTargetFolder !== null && this.dropdownTargetFolder !== undefined) {
targetFolder = this.dropdownTargetFolder;
} else {
targetFolder = this.selectedHomepageFolder || '';
}
this.closeDropdown(); this.closeDropdown();
this.mobileSidebarOpen = false; this.mobileSidebarOpen = false;
this.createNameModalKind = kind; this.createNameModalKind = kind;

View File

@ -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);" 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)'" onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='var(--bg-secondary)'" 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'))"
> >
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
@ -2467,12 +2467,12 @@
class="text-xl font-semibold border-none focus:outline-none focus:ring-2 rounded px-3 py-1.5" 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;' : '')" :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;' : '')"
> >
<!-- Undo Button --> <!-- Undo Button (note history or drawing strokes when viewing a drawing) -->
<button <button
@click="undo()" @click="currentMediaType === 'drawing' ? drawingUndo() : undo()"
:disabled="undoHistory.length <= 1" :disabled="currentMediaType === 'drawing' ? drawingOps.length === 0 : undoHistory.length <= 1"
class="p-2 rounded-lg" class="p-2 rounded-lg"
:style="undoHistory.length <= 1 ? 'color: var(--text-tertiary); opacity: 0.4; cursor: not-allowed;' : 'color: var(--text-secondary);'" :style="(currentMediaType === 'drawing' ? drawingOps.length === 0 : undoHistory.length <= 1) ? 'color: var(--text-tertiary); opacity: 0.4; cursor: not-allowed;' : 'color: var(--text-secondary);'"
onmouseover="if(!this.disabled) this.style.backgroundColor='var(--bg-hover)'" onmouseover="if(!this.disabled) this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='transparent'" onmouseout="this.style.backgroundColor='transparent'"
:title="t('toolbar.undo')" :title="t('toolbar.undo')"
@ -2484,10 +2484,10 @@
<!-- Redo Button --> <!-- Redo Button -->
<button <button
@click="redo()" @click="currentMediaType === 'drawing' ? drawingRedo() : redo()"
:disabled="redoHistory.length === 0" :disabled="currentMediaType === 'drawing' ? drawingRedoStack.length === 0 : redoHistory.length === 0"
class="p-2 rounded-lg" class="p-2 rounded-lg"
:style="redoHistory.length === 0 ? 'color: var(--text-tertiary); opacity: 0.4; cursor: not-allowed;' : 'color: var(--text-secondary);'" :style="(currentMediaType === 'drawing' ? drawingRedoStack.length === 0 : redoHistory.length === 0) ? 'color: var(--text-tertiary); opacity: 0.4; cursor: not-allowed;' : 'color: var(--text-secondary);'"
onmouseover="if(!this.disabled) this.style.backgroundColor='var(--bg-hover)'" onmouseover="if(!this.disabled) this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='transparent'" onmouseout="this.style.backgroundColor='transparent'"
:title="t('toolbar.redo')" :title="t('toolbar.redo')"
@ -2504,7 +2504,7 @@
style="color: var(--error);" style="color: var(--error);"
onmouseover="this.style.backgroundColor='var(--bg-hover)'" onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='transparent'" onmouseout="this.style.backgroundColor='transparent'"
:title="currentMedia ? t('toolbar.delete_image') : t('toolbar.delete_note')" :title="currentMedia ? (currentMediaType === 'drawing' ? t('toolbar.delete_drawing') : t('toolbar.delete_image')) : t('toolbar.delete_note')"
> >
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
@ -2654,6 +2654,80 @@
</div> </div>
</div> </div>
<!-- Drawing tools (below main toolbar; undo/redo use icons above) -->
<div
x-show="currentMedia && currentMediaType === 'drawing'"
class="px-4 py-2 flex flex-wrap gap-2 items-center border-b flex-shrink-0 zen-hide"
style="background-color: var(--bg-secondary); border-color: var(--border-primary);"
x-cloak
>
<button
type="button"
class="p-2 rounded-lg border transition-colors"
:style="drawingTool === 'freehand' ? 'background-color: var(--accent-primary); color: white; border-color: var(--accent-primary);' : 'background-color: var(--bg-tertiary); color: var(--text-secondary); border-color: var(--border-primary);'"
@click="drawingTool = 'freehand'"
:title="t('drawing.tool_freehand')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
</button>
<button
type="button"
class="p-2 rounded-lg border transition-colors"
:style="drawingTool === 'line' ? 'background-color: var(--accent-primary); color: white; border-color: var(--accent-primary);' : 'background-color: var(--bg-tertiary); color: var(--text-secondary); border-color: var(--border-primary);'"
@click="drawingTool = 'line'"
:title="t('drawing.tool_line')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 20L20 4" />
</svg>
</button>
<button
type="button"
class="p-2 rounded-lg border transition-colors"
:style="drawingTool === 'rect' ? 'background-color: var(--accent-primary); color: white; border-color: var(--accent-primary);' : 'background-color: var(--bg-tertiary); color: var(--text-secondary); border-color: var(--border-primary);'"
@click="drawingTool = 'rect'"
:title="t('drawing.tool_rect')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a2 2 0 012-2h12a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V5z" />
</svg>
</button>
<button
type="button"
class="p-2 rounded-lg border transition-colors"
:style="drawingTool === 'ellipse' ? 'background-color: var(--accent-primary); color: white; border-color: var(--accent-primary);' : 'background-color: var(--bg-tertiary); color: var(--text-secondary); border-color: var(--border-primary);'"
@click="drawingTool = 'ellipse'"
:title="t('drawing.tool_ellipse')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</button>
<div class="w-px h-5 mx-1" style="background-color: var(--border-primary);"></div>
<label class="flex items-center gap-1 text-xs cursor-pointer" style="color: var(--text-secondary);">
<span x-text="t('drawing.color')"></span>
<input type="color" x-model="drawingColor" class="w-8 h-7 p-0 border rounded cursor-pointer" style="border-color: var(--border-primary);" />
</label>
<label class="flex items-center gap-1 text-xs" style="color: var(--text-secondary);">
<span x-text="t('drawing.width')"></span>
<input type="range" min="1" max="32" x-model.number="drawingLineWidth" class="w-24 md:w-32 align-middle" />
<span class="tabular-nums w-5" x-text="drawingLineWidth"></span>
</label>
<button
type="button"
class="p-2 rounded-lg ml-auto transition-colors"
style="background-color: var(--accent-primary); color: white;"
@click="drawingSave()"
:title="t('drawing.save_hint')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
</button>
</div>
<!-- Editor/Preview/Graph --> <!-- Editor/Preview/Graph -->
<div class="flex-1 flex relative" style="min-height: 0;"> <div class="flex-1 flex relative" style="min-height: 0;">
<!-- Editor --> <!-- Editor -->
@ -2875,8 +2949,9 @@
<!-- Media Viewer (images, audio, video, PDF) --> <!-- Media Viewer (images, audio, video, PDF) -->
<template x-if="currentMedia"> <template x-if="currentMedia">
<div <div
class="flex-1 flex items-center justify-center overflow-auto" class="flex-1 flex overflow-auto min-h-0"
style="background-color: var(--bg-primary); min-height: 0;" :class="currentMediaType === 'drawing' ? 'items-stretch justify-stretch' : 'items-center justify-center'"
style="background-color: var(--bg-primary);"
> >
<!-- Image --> <!-- Image -->
<template x-if="currentMediaType === 'image'"> <template x-if="currentMediaType === 'image'">
@ -2921,6 +2996,30 @@
:title="currentMedia.split('/').pop()" :title="currentMedia.split('/').pop()"
></iframe> ></iframe>
</template> </template>
<!-- Drawing (PNG named drawing-*.png); tools row is in header area above -->
<template x-if="currentMediaType === 'drawing'">
<div
class="flex flex-col flex-1 w-full min-w-0 min-h-0 self-stretch"
:key="currentMedia"
>
<div
x-ref="drawingCanvasWrap"
class="flex-1 w-full min-h-0 min-w-0"
style="min-height: 0;"
>
<canvas
x-ref="drawingCanvas"
class="drawing-surface block w-full h-full"
style="touch-action: none; cursor: crosshair; background: #fff;"
@pointerdown.prevent="drawingPointerDown($event)"
@pointermove.prevent="drawingPointerMove($event)"
@pointerup.prevent="drawingPointerUp($event)"
@pointercancel.prevent="drawingPointerUp($event)"
></canvas>
</div>
</div>
</template>
</div> </div>
</template> </template>
</div> </div>
@ -3115,6 +3214,16 @@
<span class="text-lg">📄</span> <span class="text-lg">📄</span>
<span x-text="t('sidebar.new_from_template')"></span> <span x-text="t('sidebar.new_from_template')"></span>
</button> </button>
<button
@click="createNewDrawing()"
class="w-full px-4 py-2.5 text-sm text-left flex items-center gap-3 transition-colors"
style="color: var(--text-primary);"
onmouseover="this.style.backgroundColor='var(--bg-hover)'"
onmouseout="this.style.backgroundColor='transparent'"
>
<span class="text-lg">✏️</span>
<span x-text="t('sidebar.new_drawing')"></span>
</button>
</div> </div>
</div> </div>

View File

@ -42,6 +42,7 @@
"new_note": "Neue Notiz", "new_note": "Neue Notiz",
"new_folder": "Neuer Ordner", "new_folder": "Neuer Ordner",
"new_from_template": "Neu aus Vorlage", "new_from_template": "Neu aus Vorlage",
"new_drawing": "Neue Zeichnung",
"rename_folder": "Ordner umbenennen", "rename_folder": "Ordner umbenennen",
"delete_folder": "Ordner löschen", "delete_folder": "Ordner löschen",
"search_placeholder": "Notizen durchsuchen...", "search_placeholder": "Notizen durchsuchen...",
@ -124,6 +125,7 @@
"redo": "Wiederholen (Strg+Y)", "redo": "Wiederholen (Strg+Y)",
"delete_note": "Notiz löschen", "delete_note": "Notiz löschen",
"delete_image": "Bild löschen", "delete_image": "Bild löschen",
"delete_drawing": "Zeichnung löschen",
"export_html": "Als HTML exportieren", "export_html": "Als HTML exportieren",
"print_preview": "Druckvorschau", "print_preview": "Druckvorschau",
"copy_link": "Link in Zwischenablage kopieren", "copy_link": "Link in Zwischenablage kopieren",
@ -226,6 +228,17 @@
"error_incorrect_password": "Falsches Passwort. Bitte versuche es erneut." "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": { "media": {
"confirm_delete": "\"{{name}}\" löschen?", "confirm_delete": "\"{{name}}\" löschen?",
"upload_failed": "Datei-Upload fehlgeschlagen", "upload_failed": "Datei-Upload fehlgeschlagen",

View File

@ -42,6 +42,7 @@
"new_note": "New Note", "new_note": "New Note",
"new_folder": "New Folder", "new_folder": "New Folder",
"new_from_template": "New from Template", "new_from_template": "New from Template",
"new_drawing": "New Drawing",
"rename_folder": "Rename folder", "rename_folder": "Rename folder",
"delete_folder": "Delete folder", "delete_folder": "Delete folder",
"search_placeholder": "Search notes...", "search_placeholder": "Search notes...",
@ -123,6 +124,7 @@
"redo": "Redo (Ctrl+Y)", "redo": "Redo (Ctrl+Y)",
"delete_note": "Delete note", "delete_note": "Delete note",
"delete_image": "Delete image", "delete_image": "Delete image",
"delete_drawing": "Delete drawing",
"export_html": "Export as HTML", "export_html": "Export as HTML",
"print_preview": "Print preview", "print_preview": "Print preview",
"copy_link": "Copy link to clipboard", "copy_link": "Copy link to clipboard",
@ -225,6 +227,17 @@
"error_incorrect_password": "Incorrect password. Please try again." "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": { "media": {
"confirm_delete": "Delete \"{{name}}\"?", "confirm_delete": "Delete \"{{name}}\"?",
"upload_failed": "Failed to upload file", "upload_failed": "Failed to upload file",

View File

@ -42,6 +42,7 @@
"new_note": "New Note", "new_note": "New Note",
"new_folder": "New Folder", "new_folder": "New Folder",
"new_from_template": "New from Template", "new_from_template": "New from Template",
"new_drawing": "New Drawing",
"rename_folder": "Rename folder", "rename_folder": "Rename folder",
"delete_folder": "Delete folder", "delete_folder": "Delete folder",
"search_placeholder": "Search notes...", "search_placeholder": "Search notes...",
@ -124,6 +125,7 @@
"redo": "Redo (Ctrl+Y)", "redo": "Redo (Ctrl+Y)",
"delete_note": "Delete note", "delete_note": "Delete note",
"delete_image": "Delete image", "delete_image": "Delete image",
"delete_drawing": "Delete drawing",
"export_html": "Export as HTML", "export_html": "Export as HTML",
"print_preview": "Print preview", "print_preview": "Print preview",
"copy_link": "Copy link to clipboard", "copy_link": "Copy link to clipboard",
@ -226,6 +228,17 @@
"error_incorrect_password": "Incorrect password. Please try again." "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": { "media": {
"confirm_delete": "Delete \"{{name}}\"?", "confirm_delete": "Delete \"{{name}}\"?",
"upload_failed": "Failed to upload file", "upload_failed": "Failed to upload file",

View File

@ -42,6 +42,7 @@
"new_note": "Nueva Nota", "new_note": "Nueva Nota",
"new_folder": "Nueva Carpeta", "new_folder": "Nueva Carpeta",
"new_from_template": "Nueva desde Plantilla", "new_from_template": "Nueva desde Plantilla",
"new_drawing": "Nuevo dibujo",
"rename_folder": "Renombrar carpeta", "rename_folder": "Renombrar carpeta",
"delete_folder": "Eliminar carpeta", "delete_folder": "Eliminar carpeta",
"search_placeholder": "Buscar notas...", "search_placeholder": "Buscar notas...",
@ -124,6 +125,7 @@
"redo": "Rehacer (Ctrl+Y)", "redo": "Rehacer (Ctrl+Y)",
"delete_note": "Eliminar nota", "delete_note": "Eliminar nota",
"delete_image": "Eliminar imagen", "delete_image": "Eliminar imagen",
"delete_drawing": "Eliminar dibujo",
"export_html": "Exportar como HTML", "export_html": "Exportar como HTML",
"print_preview": "Vista previa de impresión", "print_preview": "Vista previa de impresión",
"copy_link": "Copiar enlace al portapapeles", "copy_link": "Copiar enlace al portapapeles",
@ -226,6 +228,17 @@
"error_incorrect_password": "Contraseña incorrecta. Por favor, inténtalo de nuevo." "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": { "media": {
"confirm_delete": "¿Eliminar \"{{name}}\"?", "confirm_delete": "¿Eliminar \"{{name}}\"?",
"upload_failed": "Error al subir archivo", "upload_failed": "Error al subir archivo",

View File

@ -42,6 +42,7 @@
"new_note": "Nouvelle Note", "new_note": "Nouvelle Note",
"new_folder": "Nouveau Dossier", "new_folder": "Nouveau Dossier",
"new_from_template": "Nouveau depuis Modèle", "new_from_template": "Nouveau depuis Modèle",
"new_drawing": "Nouveau dessin",
"rename_folder": "Renommer le dossier", "rename_folder": "Renommer le dossier",
"delete_folder": "Supprimer le dossier", "delete_folder": "Supprimer le dossier",
"search_placeholder": "Rechercher des notes...", "search_placeholder": "Rechercher des notes...",
@ -124,6 +125,7 @@
"redo": "Rétablir (Ctrl+Y)", "redo": "Rétablir (Ctrl+Y)",
"delete_note": "Supprimer la note", "delete_note": "Supprimer la note",
"delete_image": "Supprimer l'image", "delete_image": "Supprimer l'image",
"delete_drawing": "Supprimer le dessin",
"export_html": "Exporter en HTML", "export_html": "Exporter en HTML",
"print_preview": "Aperçu avant impression", "print_preview": "Aperçu avant impression",
"copy_link": "Copier le lien dans le presse-papiers", "copy_link": "Copier le lien dans le presse-papiers",
@ -226,6 +228,17 @@
"error_incorrect_password": "Mot de passe incorrect. Veuillez réessayer." "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": { "media": {
"confirm_delete": "Supprimer \"{{name}}\" ?", "confirm_delete": "Supprimer \"{{name}}\" ?",
"upload_failed": "Échec du téléchargement du fichier", "upload_failed": "Échec du téléchargement du fichier",

View File

@ -42,6 +42,7 @@
"new_note": "Új Jegyzet", "new_note": "Új Jegyzet",
"new_folder": "Új Mappa", "new_folder": "Új Mappa",
"new_from_template": "Új Sablonból", "new_from_template": "Új Sablonból",
"new_drawing": "Új rajz",
"rename_folder": "Mappa átnevezése", "rename_folder": "Mappa átnevezése",
"delete_folder": "Mappa törlése", "delete_folder": "Mappa törlése",
"search_placeholder": "Jegyzetek keresése...", "search_placeholder": "Jegyzetek keresése...",
@ -124,6 +125,7 @@
"redo": "Helyrehoz (Ctrl+Y)", "redo": "Helyrehoz (Ctrl+Y)",
"delete_note": "Jegyzet törlése", "delete_note": "Jegyzet törlése",
"delete_image": "Kép törlése", "delete_image": "Kép törlése",
"delete_drawing": "Rajz törlése",
"export_html": "Exportálás HTML-ként", "export_html": "Exportálás HTML-ként",
"print_preview": "Nyomtatási előnézet", "print_preview": "Nyomtatási előnézet",
"copy_link": "Link másolása", "copy_link": "Link másolása",
@ -226,6 +228,17 @@
"error_incorrect_password": "Helytelen jelszó. Próbáld újra." "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": { "media": {
"confirm_delete": "Biztos törlöd a következőt: \"{{name}}\"?", "confirm_delete": "Biztos törlöd a következőt: \"{{name}}\"?",
"upload_failed": "Hiba a fájl feltöltése során", "upload_failed": "Hiba a fájl feltöltése során",

View File

@ -42,6 +42,7 @@
"new_note": "Nuova Nota", "new_note": "Nuova Nota",
"new_folder": "Nuova Cartella", "new_folder": "Nuova Cartella",
"new_from_template": "Nuovo da Modello", "new_from_template": "Nuovo da Modello",
"new_drawing": "Nuovo disegno",
"rename_folder": "Rinomina cartella", "rename_folder": "Rinomina cartella",
"delete_folder": "Elimina cartella", "delete_folder": "Elimina cartella",
"search_placeholder": "Cerca note...", "search_placeholder": "Cerca note...",
@ -123,6 +124,7 @@
"redo": "Ripeti (Ctrl+Y)", "redo": "Ripeti (Ctrl+Y)",
"delete_note": "Elimina nota", "delete_note": "Elimina nota",
"delete_image": "Elimina immagine", "delete_image": "Elimina immagine",
"delete_drawing": "Elimina disegno",
"export_html": "Esporta come HTML", "export_html": "Esporta come HTML",
"print_preview": "Anteprima di stampa", "print_preview": "Anteprima di stampa",
"copy_link": "Copia link negli appunti", "copy_link": "Copia link negli appunti",
@ -225,6 +227,17 @@
"error_incorrect_password": "Password errata. Riprova." "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": { "media": {
"confirm_delete": "Eliminare \"{{name}}\"?", "confirm_delete": "Eliminare \"{{name}}\"?",
"upload_failed": "Caricamento file fallito", "upload_failed": "Caricamento file fallito",

View File

@ -42,6 +42,7 @@
"new_note": "新規ノート", "new_note": "新規ノート",
"new_folder": "新規フォルダ", "new_folder": "新規フォルダ",
"new_from_template": "テンプレートから作成", "new_from_template": "テンプレートから作成",
"new_drawing": "新規お絵かき",
"rename_folder": "フォルダの名前を変更", "rename_folder": "フォルダの名前を変更",
"delete_folder": "フォルダを削除", "delete_folder": "フォルダを削除",
"search_placeholder": "ノートを検索...", "search_placeholder": "ノートを検索...",
@ -123,6 +124,7 @@
"redo": "やり直す (Ctrl+Y)", "redo": "やり直す (Ctrl+Y)",
"delete_note": "ノートを削除", "delete_note": "ノートを削除",
"delete_image": "画像を削除", "delete_image": "画像を削除",
"delete_drawing": "お絵かきを削除",
"export_html": "HTMLとしてエクスポート", "export_html": "HTMLとしてエクスポート",
"print_preview": "印刷プレビュー", "print_preview": "印刷プレビュー",
"copy_link": "リンクをクリップボードにコピー", "copy_link": "リンクをクリップボードにコピー",
@ -225,6 +227,17 @@
"error_incorrect_password": "パスワードが正しくありません。もう一度お試しください。" "error_incorrect_password": "パスワードが正しくありません。もう一度お試しください。"
}, },
"drawing": {
"tool_freehand": "鉛筆(フリーハンド)",
"tool_line": "直線",
"tool_rect": "長方形",
"tool_ellipse": "円",
"color": "色",
"width": "線の太さ",
"saved": "保存しました",
"save_hint": "お絵かきを保存Ctrl+S"
},
"media": { "media": {
"confirm_delete": "「{{name}}」を削除しますか?", "confirm_delete": "「{{name}}」を削除しますか?",
"upload_failed": "ファイルのアップロードに失敗しました", "upload_failed": "ファイルのアップロードに失敗しました",

View File

@ -42,6 +42,7 @@
"new_note": "Новая заметка", "new_note": "Новая заметка",
"new_folder": "Новая папка", "new_folder": "Новая папка",
"new_from_template": "Из шаблона", "new_from_template": "Из шаблона",
"new_drawing": "Новый рисунок",
"rename_folder": "Переименовать папку", "rename_folder": "Переименовать папку",
"delete_folder": "Удалить папку", "delete_folder": "Удалить папку",
"search_placeholder": "Поиск заметок...", "search_placeholder": "Поиск заметок...",
@ -123,6 +124,7 @@
"redo": "Повторить (Ctrl+Y)", "redo": "Повторить (Ctrl+Y)",
"delete_note": "Удалить заметку", "delete_note": "Удалить заметку",
"delete_image": "Удалить изображение", "delete_image": "Удалить изображение",
"delete_drawing": "Удалить рисунок",
"export_html": "Экспорт в HTML", "export_html": "Экспорт в HTML",
"print_preview": "Предварительный просмотр печати", "print_preview": "Предварительный просмотр печати",
"copy_link": "Копировать ссылку", "copy_link": "Копировать ссылку",
@ -225,6 +227,17 @@
"error_incorrect_password": "Неверный пароль. Попробуйте снова." "error_incorrect_password": "Неверный пароль. Попробуйте снова."
}, },
"drawing": {
"tool_freehand": "Карандаш — от руки",
"tool_line": "Прямая линия",
"tool_rect": "Прямоугольник",
"tool_ellipse": "Круг",
"color": "Цвет",
"width": "Толщина линии",
"saved": "Рисунок сохранён",
"save_hint": "Сохранить рисунок (Ctrl+S)"
},
"media": { "media": {
"confirm_delete": "Удалить «{{name}}»?", "confirm_delete": "Удалить «{{name}}»?",
"upload_failed": "Не удалось загрузить файл", "upload_failed": "Не удалось загрузить файл",

View File

@ -42,6 +42,7 @@
"new_note": "Nov zapis", "new_note": "Nov zapis",
"new_folder": "Nova mapa", "new_folder": "Nova mapa",
"new_from_template": "Novo iz predloge", "new_from_template": "Novo iz predloge",
"new_drawing": "Nov risba",
"rename_folder": "Preimenuj mapo", "rename_folder": "Preimenuj mapo",
"delete_folder": "Izbriši mapo", "delete_folder": "Izbriši mapo",
"search_placeholder": "Išči zapiske...", "search_placeholder": "Išči zapiske...",
@ -123,6 +124,7 @@
"redo": "Ponovi (Ctrl+Y)", "redo": "Ponovi (Ctrl+Y)",
"delete_note": "Izbriši zapis", "delete_note": "Izbriši zapis",
"delete_image": "Izbriši sliko", "delete_image": "Izbriši sliko",
"delete_drawing": "Izbriši risbo",
"export_html": "Izvozi kot HTML", "export_html": "Izvozi kot HTML",
"print_preview": "Predogled tiskanja", "print_preview": "Predogled tiskanja",
"copy_link": "Kopiraj povezavo v odložišče", "copy_link": "Kopiraj povezavo v odložišče",
@ -225,6 +227,17 @@
"error_incorrect_password": "Napačno geslo. Prosimo, poskusite znova." "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": { "media": {
"confirm_delete": "Izbrišem \"{{name}}\"?", "confirm_delete": "Izbrišem \"{{name}}\"?",
"upload_failed": "Nalaganje datoteke ni uspelo", "upload_failed": "Nalaganje datoteke ni uspelo",

View File

@ -42,6 +42,7 @@
"new_note": "新建笔记", "new_note": "新建笔记",
"new_folder": "新建文件夹", "new_folder": "新建文件夹",
"new_from_template": "从模板新建", "new_from_template": "从模板新建",
"new_drawing": "新建绘图",
"rename_folder": "重命名文件夹", "rename_folder": "重命名文件夹",
"delete_folder": "删除文件夹", "delete_folder": "删除文件夹",
"search_placeholder": "搜索笔记...", "search_placeholder": "搜索笔记...",
@ -123,6 +124,7 @@
"redo": "重做 (Ctrl+Y)", "redo": "重做 (Ctrl+Y)",
"delete_note": "删除笔记", "delete_note": "删除笔记",
"delete_image": "删除图片", "delete_image": "删除图片",
"delete_drawing": "删除绘图",
"export_html": "导出为 HTML", "export_html": "导出为 HTML",
"print_preview": "打印预览", "print_preview": "打印预览",
"copy_link": "复制链接到剪贴板", "copy_link": "复制链接到剪贴板",
@ -225,6 +227,17 @@
"error_incorrect_password": "密码错误。请重试。" "error_incorrect_password": "密码错误。请重试。"
}, },
"drawing": {
"tool_freehand": "铅笔 — 自由绘制",
"tool_line": "直线",
"tool_rect": "矩形",
"tool_ellipse": "圆形",
"color": "颜色",
"width": "线条粗细",
"saved": "绘图已保存",
"save_hint": "保存绘图Ctrl+S"
},
"media": { "media": {
"confirm_delete": "删除 \"{{name}}\"", "confirm_delete": "删除 \"{{name}}\"",
"upload_failed": "上传文件失败", "upload_failed": "上传文件失败",