Merge pull request #220 from gamosoft/features/text-drawing

Features/text drawing
This commit is contained in:
Guillermo Villar 2026-05-13 10:14:48 +02:00 committed by GitHub
commit 3289102190
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 228 additions and 5 deletions

View File

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

View File

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

View File

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

View File

@ -620,6 +620,15 @@ function noteApp() {
drawingRedoStack: [],
drawingDraft: null,
drawingIsPointerDown: false,
// Text tool — pending input state. The HTML overlay <input> 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 <input> 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 <input>
* 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;

View File

@ -2802,6 +2802,17 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19.5 14.5c0 1-1.5 3-1.5 3s-1.5-2-1.5-3a1.5 1.5 0 113 0z" />
</svg>
</button>
<button
type="button"
class="p-2 rounded-lg border transition-colors"
:style="drawingTool === 'text' ? '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 = 'text'"
:title="t('drawing.tool_text')"
>
<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="M5 4h14M12 4v16M8 20h8" />
</svg>
</button>
<button
type="button"
class="p-2 rounded-lg border transition-colors"
@ -3130,7 +3141,7 @@
>
<div
x-ref="drawingCanvasWrap"
class="flex-1 w-full min-h-0 min-w-0 flex items-center justify-center"
class="flex-1 w-full min-h-0 min-w-0 flex items-center justify-center relative"
style="min-height: 0; background-color: var(--bg-secondary);"
>
<canvas
@ -3140,12 +3151,54 @@
? 'touch-action: none; cursor: copy; background: #fff;'
: drawingTool === 'fill'
? 'touch-action: none; cursor: cell; background: #fff;'
: 'touch-action: none; cursor: crosshair; background: #fff;'"
: drawingTool === 'text'
? 'touch-action: none; cursor: text; background: #fff;'
: 'touch-action: none; cursor: crosshair; background: #fff;'"
@pointerdown.prevent="drawingPointerDown($event)"
@pointermove.prevent="drawingPointerMove($event)"
@pointerup.prevent="drawingPointerUp($event)"
@pointercancel.prevent="drawingPointerUp($event)"
></canvas>
<!-- Text-tool floating editor — only visible while placing text.
The contenteditable's typed glyphs are kept INVISIBLE
(color: transparent) on purpose: HTML and canvas text-rendering
land glyphs on subtly different pixels even with identical font /
size / line-height, so showing both would always result in a tiny
jump on commit. Instead we render the typed text live on the
canvas (via drawingDraft), so what the user sees while typing IS
the canvas output — pixel-identical to the rasterized op on Enter.
Only the caret (caret-color) and the dashed outline remain
visible to indicate where the text will land. -->
<div
id="drawing-text-input"
x-show="drawingTextActive"
contenteditable="plaintext-only"
spellcheck="false"
@keydown.enter.prevent="drawingTextCommit()"
@keydown.escape.prevent="drawingTextCancel()"
@blur="drawingTextCommit()"
@input="drawingTextOnInput($event)"
:style="`position: absolute;
left: ${drawingTextCssX}px;
top: ${drawingTextCssY}px;
font-size: ${Math.max(12, drawingTextDocFontSize * ((($refs.drawingCanvas && $refs.drawingCanvas.clientWidth) || drawingDocW) / drawingDocW))}px;
line-height: 1;
font-family: sans-serif;
color: transparent;
caret-color: ${drawingColor};
background: transparent;
border: 0;
border-radius: 0;
padding: 0;
margin: 0;
outline: 1px dashed ${drawingColor};
outline-offset: -1px;
box-shadow: none;
z-index: 5;
min-width: 4ch;
white-space: pre;`"
x-cloak
></div>
</div>
</div>
</template>

View File

@ -220,6 +220,7 @@
"tool_eraser": "Radierer (malt mit Hintergrundfarbe)",
"tool_eyedropper": "Pipette — Farbe vom Bild aufnehmen",
"tool_fill": "Farbeimer — verbundenen Bereich mit Farbe füllen",
"tool_text": "Text — auf die Leinwand klicken, um zu schreiben",
"clear": "Leeren",
"clear_hint": "Durch leeres Bild ersetzen",
"clear_title": "Durch leere Leinwand ersetzen?",

View File

@ -219,6 +219,7 @@
"tool_eraser": "Eraser (paints with background colour)",
"tool_eyedropper": "Eyedropper — pick a colour from the canvas",
"tool_fill": "Paint bucket — fill connected area with colour",
"tool_text": "Text — click on the canvas to type",
"clear": "Clear",
"clear_hint": "Replace with a blank image",
"clear_title": "Replace with a blank canvas?",

View File

@ -220,6 +220,7 @@
"tool_eraser": "Eraser (paints with background color)",
"tool_eyedropper": "Eyedropper — pick a color from the canvas",
"tool_fill": "Paint bucket — fill connected area with color",
"tool_text": "Text — click on the canvas to type",
"clear": "Clear",
"clear_hint": "Replace with a blank image",
"clear_title": "Replace with a blank canvas?",

View File

@ -220,6 +220,7 @@
"tool_eraser": "Borrador (pinta con el color de fondo)",
"tool_eyedropper": "Cuentagotas — tomar color del lienzo",
"tool_fill": "Cubo de pintura — rellenar área conectada con color",
"tool_text": "Texto — haz clic en el lienzo para escribir",
"clear": "Borrar",
"clear_hint": "Sustituir por imagen en blanco",
"clear_title": "¿Sustituir por lienzo en blanco?",

View File

@ -220,6 +220,7 @@
"tool_eraser": "Gomme (peint avec la couleur de fond)",
"tool_eyedropper": "Pipette — choisir une couleur sur le canevas",
"tool_fill": "Pot de peinture — remplir la zone connectée avec la couleur",
"tool_text": "Texte — cliquez sur le canevas pour écrire",
"clear": "Effacer",
"clear_hint": "Remplacer par une image vide",
"clear_title": "Remplacer par un canevas vide ?",

View File

@ -220,6 +220,7 @@
"tool_eraser": "Radír (háttérszínnel fest)",
"tool_eyedropper": "Pipetta — szín mintavétele a vászonról",
"tool_fill": "Festékesvödör — összefüggő terület kitöltése színnel",
"tool_text": "Szöveg — kattintson a vászonra a beíráshoz",
"clear": "Törlés",
"clear_hint": "Üres képre cserélés",
"clear_title": "Lecseréled üres vászonra?",

View File

@ -219,6 +219,7 @@
"tool_eraser": "Gomma (disegna col colore di sfondo)",
"tool_eyedropper": "Contagocce — prendi un colore dal canvas",
"tool_fill": "Secchiello — riempi l'area connessa con il colore",
"tool_text": "Testo — clicca sulla tela per scrivere",
"clear": "Svuota",
"clear_hint": "Sostituisci con immagine vuota",
"clear_title": "Sostituire con tela bianca?",

View File

@ -219,6 +219,7 @@
"tool_eraser": "消しゴム(背景色で塗る)",
"tool_eyedropper": "スポイト — キャンバスから色を取得",
"tool_fill": "塗りつぶし — つながった領域を色で塗りつぶす",
"tool_text": "テキスト — キャンバスをクリックして入力",
"clear": "クリア",
"clear_hint": "白紙の画像に置き換え",
"clear_title": "白紙のキャンバスに置き換えますか?",

View File

@ -219,6 +219,7 @@
"tool_eraser": "Ластик (рисует цветом фона)",
"tool_eyedropper": "Пипетка — взять цвет с холста",
"tool_fill": "Заливка — заполнить связанную область цветом",
"tool_text": "Текст — нажмите на холст, чтобы ввести",
"clear": "Очистить",
"clear_hint": "Заменить пустым изображением",
"clear_title": "Заменить пустым холстом?",

View File

@ -219,6 +219,7 @@
"tool_eraser": "Radirka (riše z barvo ozadja)",
"tool_eyedropper": "Kapalka — vzemi barvo s platna",
"tool_fill": "Vedro za barvanje — zapolni povezano območje z barvo",
"tool_text": "Besedilo — kliknite na platno za pisanje",
"clear": "Počisti",
"clear_hint": "Zamenjaj s prazno sliko",
"clear_title": "Zamenjaj s praznim platnom?",

View File

@ -219,6 +219,7 @@
"tool_eraser": "橡皮擦(使用背景色绘制)",
"tool_eyedropper": "吸管 — 从画布取色",
"tool_fill": "油漆桶 — 用颜色填充连通区域",
"tool_text": "文本 — 点击画布开始输入",
"clear": "清除",
"clear_hint": "替换为空白图像",
"clear_title": "替换为空白画布?",