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 @@
+