diff --git a/documentation/API.md b/documentation/API.md index cbb995e..ad21981 100644 --- a/documentation/API.md +++ b/documentation/API.md @@ -708,6 +708,7 @@ Creates a new note from a template with placeholder replacement. - `{{day}}` - Current day (DD) - `{{title}}` - Note name without extension - `{{folder}}` - Parent folder name +- `{{date:FMT}}` / `{{time:FMT}}` / `{{datetime:FMT}}` - Custom [`strftime`](https://docs.python.org/3/library/datetime.html#strftime-and-strptime-format-codes) format (e.g. `{{datetime:%Y%m%d%H%M%S}}` → `20251126153045`, `{{date:%A}}` → `Wednesday`); invalid format strings are left verbatim. See [TEMPLATES.md](TEMPLATES.md) for a recipe table. **Response:** ```json diff --git a/documentation/DRAWING.md b/documentation/DRAWING.md index 3beed15..d3d07ce 100644 --- a/documentation/DRAWING.md +++ b/documentation/DRAWING.md @@ -8,8 +8,8 @@ Use the **+ New** menu in the sidebar and choose **New drawing**. The app create ## Editor overview -- **Tools** — Freehand pencil, straight line, rectangle, and ellipse; **eraser** (paints with the canvas background color); **eyedropper** to sample a color from the canvas; **paint bucket** to flood-fill a connected area with the current color (undo/redo supported like any other stroke). -- **Color & stroke width** — Color picker and width slider appear on the same toolbar as the tools. +- **Tools** — Freehand pencil, straight line, rectangle, and ellipse; **eraser** (paints with the canvas background color); **eyedropper** to sample a color from the canvas; **paint bucket** to flood-fill a connected area with the current color; **text** to drop a typed label at the click point. Undo/redo is supported like any other stroke. +- **Color & stroke width** — Color picker and width slider appear on the same toolbar as the tools. Text size also follows the stroke-width slider. - **Undo / redo** — Use the main toolbar buttons or **Ctrl+Z** / **Ctrl+Y** (same shortcuts as the note editor; they apply to strokes while a drawing is open). - **Clear** — Replaces the current session with a **blank white image** and schedules a save (see [FEATURES.md](FEATURES.md) for the exact behavior and confirmation). - **Saving** — Changes are saved automatically after you finish a stroke (debounced), and you can press **Ctrl+S** (Cmd+S on Mac) to save the PNG immediately. diff --git a/documentation/FEATURES.md b/documentation/FEATURES.md index 6bb4b11..7179b46 100644 --- a/documentation/FEATURES.md +++ b/documentation/FEATURES.md @@ -48,7 +48,7 @@ Sketch beside your notes without leaving NoteDiscovery. **+ New → New drawing** creates a **`drawing-{timestamp}.png`** next to your markdown files; those open in the drawing viewer, other images open as usual. -- **Tools** — Pencil, lines, rectangles, ellipses, eraser, eyedropper, clear; color and stroke width on the toolbar; undo/redo while you work. +- **Tools** — Pencil, lines, rectangles, ellipses, eraser, eyedropper, paint bucket, text labels, clear; color and stroke width on the toolbar; undo/redo while you work. - **Saving** — Autosave and **Ctrl+S** / **Cmd+S** like the rest of the app. - **Files** — Plain PNGs in your vault—link them in notes and back them up with everything else. diff --git a/frontend/app.js b/frontend/app.js index f6253b2..6c450e8 100644 --- a/frontend/app.js +++ b/frontend/app.js @@ -620,6 +620,15 @@ function noteApp() { drawingRedoStack: [], drawingDraft: null, drawingIsPointerDown: false, + // Text tool — pending input state. The HTML overlay binds to drawingTextValue + // while drawingTextActive is true; on commit a 'text' op is pushed to drawingOps. + drawingTextActive: false, + drawingTextDocX: 0, // commit position in DOC space + drawingTextDocY: 0, + drawingTextCssX: 0, // overlay position in CSS pixels (canvas-relative) + drawingTextCssY: 0, + drawingTextValue: '', + drawingTextDocFontSize: 24, // font size in DOC pixels (auto-scales to display) /** True after the PNG from disk has been decoded into _drawingBaseImage; false after Clear. */ drawingHasRasterFromFile: false, /** Prevents overlapping drawingSave() runs (Ctrl+S + autosave + fast retries). */ @@ -3029,6 +3038,17 @@ function noteApp() { } return; } + if (op.type === 'text') { + const text = op.text; + if (typeof text !== 'string' || text === '') return; + ctx.save(); + ctx.fillStyle = op.color; + ctx.font = `${op.fontSize}px sans-serif`; + ctx.textBaseline = 'top'; + ctx.fillText(text, op.x, op.y); + ctx.restore(); + return; + } if (op.type === 'stroke') { const pts = op.points; if (!pts || pts.length < 2) return; @@ -3257,6 +3277,8 @@ function noteApp() { this.drawingRedoStack = []; this.drawingDraft = null; this.drawingIsPointerDown = false; + this.drawingTextActive = false; + this.drawingTextValue = ''; this._drawingBaseImage = null; this.drawingHasRasterFromFile = false; // Reset to defaults; replaced by the loaded PNG's natural size below. @@ -3535,6 +3557,8 @@ function noteApp() { this._drawingDisposeOps(this.drawingRedoStack); this.drawingOps = []; this.drawingRedoStack = []; + this.drawingTextActive = false; + this.drawingTextValue = ''; this.drawingRedraw(); this._drawingScheduleAutosave(); }, @@ -3554,6 +3578,11 @@ function noteApp() { this.drawingFill(e); return; } + if (tool === 'text') { + e.preventDefault(); + this.drawingTextStart(e); + return; + } this._drawingCancelAutosave(); canvas.setPointerCapture(e.pointerId); this._drawingPointerId = e.pointerId; @@ -3678,6 +3707,127 @@ function noteApp() { this._drawingScheduleAutosave(); }, + /** + * Text tool — single-line, commit-once. Clicking the canvas spawns a floating + * at the click position; pressing Enter or losing focus rasterizes the text into a + * 'text' op (rendered in DOC space via ctx.fillText so it auto-scales with the canvas). + * Esc cancels without writing. Each commit pushes one op so undo/redo handles it just + * like any other shape — no separate "edit text" mode, no DOM cleanup gymnastics. + */ + drawingTextStart(e) { + const canvas = this._drawingCanvasEl; + if (!canvas) return; + // If a previous input was still open (e.g. mobile pointerdown before blur fires), + // commit it first so we never lose typed text. + if (this.drawingTextActive) { + this.drawingTextCommit(); + } + // CSS coords are stored relative to the WRAP (the input's offset parent), not the + // canvas: the wrap is a flex container that centers the canvas, so when the canvas + // is narrower than the wrap there are gutters on either side that would otherwise + // shift the floating input away from the click point. + const wrap = this.$refs.drawingCanvasWrap || canvas.parentElement; + const wrapRect = wrap.getBoundingClientRect(); + const { x: docX, y: docY } = this._drawingCanvasCoords(e); + this.drawingTextDocX = docX; + this.drawingTextDocY = docY; + this.drawingTextCssX = e.clientX - wrapRect.left; + this.drawingTextCssY = e.clientY - wrapRect.top; + this.drawingTextDocFontSize = Math.max(14, this.drawingLineWidth * 6); + this.drawingTextValue = ''; + this.drawingTextActive = true; + this.drawingDraft = null; + this.$nextTick(() => { + const el = document.getElementById('drawing-text-input'); + if (!el) return; + // Wipe any leftover text from a previous session before focusing — contenteditable + // doesn't auto-clear from setting drawingTextValue alone. + el.textContent = ''; + el.focus(); + }); + }, + + /** + * Normalize text-tool input: ctx.fillText doesn't honor line breaks (renders them + * as literal LF glyphs / tofu), and Firefox treats contenteditable="plaintext-only" + * as plain "true" so multi-line paste sneaks newlines into textContent. Also clamp + * length defensively so a giant paste can't bloat the op / autosave payload. + */ + _drawingTextSanitize(s) { + if (typeof s !== 'string' || s.length === 0) return ''; + const collapsed = s.replace(/[\r\n\t\v\f]+/g, ' '); + return collapsed.length > 1024 ? collapsed.slice(0, 1024) : collapsed; + }, + + /** + * Live-preview handler for the text tool. The contenteditable div is invisible + * (color: transparent), so the only thing the user actually SEES of their typing + * is whatever drawingRedraw paints from drawingDraft. Pushing the in-progress + * text into drawingDraft means the live preview goes through the very same + * ctx.fillText call path as the final commit — guaranteeing zero pixel drift. + */ + drawingTextOnInput(e) { + if (!this.drawingTextActive) return; + const text = this._drawingTextSanitize(e.target.textContent || ''); + this.drawingTextValue = text; + if (text.length === 0) { + this.drawingDraft = null; + } else { + this.drawingDraft = { + type: 'text', + color: this.drawingColor, + x: this.drawingTextDocX, + y: this.drawingTextDocY, + text, + fontSize: this.drawingTextDocFontSize, + }; + } + this._drawingScheduleRedraw(); + }, + + drawingTextCommit() { + if (!this.drawingTextActive) return; + // Read straight from the DOM so we capture the very last keystroke even if the + // @input event hasn't fired yet (it usually has, but Enter can race it). + const el = document.getElementById('drawing-text-input'); + const raw = el ? (el.textContent || '') : (this.drawingTextValue || ''); + const text = this._drawingTextSanitize(raw).trim(); + const docX = this.drawingTextDocX; + const docY = this.drawingTextDocY; + const fontSize = this.drawingTextDocFontSize; + const color = this.drawingColor; + this.drawingTextActive = false; + this.drawingTextValue = ''; + this.drawingDraft = null; + if (el) el.textContent = ''; + if (!text) { + this.drawingRedraw(); + return; + } + this._drawingDisposeOps(this.drawingRedoStack); + this.drawingRedoStack = []; + this.drawingOps.push({ + type: 'text', + color, + x: docX, + y: docY, + text, + fontSize, + }); + this.drawingRedraw(); + this._drawingScheduleAutosave(); + }, + + drawingTextCancel() { + if (!this.drawingTextActive) return; + this.drawingTextActive = false; + this.drawingTextValue = ''; + this.drawingDraft = null; + const el = document.getElementById('drawing-text-input'); + if (el) el.textContent = ''; + this.drawingRedraw(); + }, + /** * Persist the flattened canvas to disk (Ctrl+S, autosave). Same feedback as saveNote: * header "Saved" only — never clears stroke undo/redo or reloads the image; stacks reset when @@ -3685,6 +3835,14 @@ function noteApp() { */ async drawingSave() { if (!this.currentMedia || this.currentMediaType !== 'drawing') return; + // Commit any in-progress text BEFORE the in-flight guard so the typed value + // makes it into drawingOps before the next line wipes drawingDraft. Otherwise + // Ctrl+S (or autosave) mid-typing would silently drop whatever the user just + // typed from the saved PNG. drawingTextCommit will trigger another autosave, + // but that's harmless — the next save just re-uploads the same bytes. + if (this.drawingTextActive) { + this.drawingTextCommit(); + } if (this._drawingSaveInFlight) { this._drawingSaveQueued = true; return; diff --git a/frontend/index.html b/frontend/index.html index 86c66de..21583d5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2802,6 +2802,17 @@ +