Merge pull request #220 from gamosoft/features/text-drawing
Features/text drawing
This commit is contained in:
commit
3289102190
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
158
frontend/app.js
158
frontend/app.js
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;'
|
||||
: 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>
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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 ?",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@
|
|||
"tool_eraser": "消しゴム(背景色で塗る)",
|
||||
"tool_eyedropper": "スポイト — キャンバスから色を取得",
|
||||
"tool_fill": "塗りつぶし — つながった領域を色で塗りつぶす",
|
||||
"tool_text": "テキスト — キャンバスをクリックして入力",
|
||||
"clear": "クリア",
|
||||
"clear_hint": "白紙の画像に置き換え",
|
||||
"clear_title": "白紙のキャンバスに置き換えますか?",
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@
|
|||
"tool_eraser": "Ластик (рисует цветом фона)",
|
||||
"tool_eyedropper": "Пипетка — взять цвет с холста",
|
||||
"tool_fill": "Заливка — заполнить связанную область цветом",
|
||||
"tool_text": "Текст — нажмите на холст, чтобы ввести",
|
||||
"clear": "Очистить",
|
||||
"clear_hint": "Заменить пустым изображением",
|
||||
"clear_title": "Заменить пустым холстом?",
|
||||
|
|
|
|||
|
|
@ -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?",
|
||||
|
|
|
|||
|
|
@ -219,6 +219,7 @@
|
|||
"tool_eraser": "橡皮擦(使用背景色绘制)",
|
||||
"tool_eyedropper": "吸管 — 从画布取色",
|
||||
"tool_fill": "油漆桶 — 用颜色填充连通区域",
|
||||
"tool_text": "文本 — 点击画布开始输入",
|
||||
"clear": "清除",
|
||||
"clear_hint": "替换为空白图像",
|
||||
"clear_title": "替换为空白画布?",
|
||||
|
|
|
|||
Loading…
Reference in New Issue