Skip to content

Commit b442985

Browse files
r8a5claude
andcommitted
Fix text editing on the canvas
Three bugs were keeping users from editing text: 1. renderOverlay() called clearChildren(overlay) on every redraw, which yanked the contenteditable mid-edit. Any state change (drag end, font load, template fetch) wiped the editor. Guard: skip overlay rebuilds while state.editingTextId is set; the editor removes itself cleanly on commit/cancel so the post-edit redraw runs as normal. 2. onPointerDown ate clicks that landed inside the open editor. With the editor inside the overlay (pointer-events: auto), the pointerdown still bubbled to canvasWrap where the layer-selection code grabbed the click before contenteditable could place the caret. Guard: if the editor is open and the target is inside it, bail out early; if the target is outside, commit and continue. 3. Double-click was the only way to start editing, which made the feature feel hidden. Added Canva's single-click promotion: when a text layer is already the sole selection and you click on it again without dragging, that click goes straight to inline edit. The dblclick path still works for users who don't yet know the shortcut. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent bcd3f0d commit b442985

1 file changed

Lines changed: 38 additions & 1 deletion

File tree

public/cover-editor.js

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -823,6 +823,13 @@
823823

824824
// ── overlay rendering (selection handles + guides) ───────────
825825
function renderOverlay() {
826+
// CRITICAL: when an inline text editor is active, do nothing.
827+
// Wiping the overlay would yank the contenteditable mid-edit
828+
// and lose focus / value. The editor is removed cleanly on
829+
// commit/cancel; any redraws triggered while it's open (e.g.
830+
// by a font load or template asset finishing) must not touch
831+
// it.
832+
if (state.editingTextId && textEditor) return;
826833
clearChildren(overlay);
827834
const sel = selectedLayers();
828835
// Snap guides.
@@ -941,6 +948,17 @@
941948
return px >= l.x && px <= l.x + l.w && py >= l.y && py <= l.y + l.h;
942949
}
943950
function onPointerDown(ev) {
951+
// If an inline text editor is open: clicks INSIDE it should
952+
// bubble naturally (caret placement etc); clicks OUTSIDE commit
953+
// and end editing, then proceed with the normal selection
954+
// logic. Without this guard, clicking on the editor's text was
955+
// being intercepted by selection code and the contenteditable
956+
// never saw the click.
957+
if (state.editingTextId && textEditor) {
958+
if (textEditor.contains(ev.target)) return; // let the editor handle it
959+
endInlineTextEdit(true);
960+
// Fall through to normal selection.
961+
}
944962
// Was a handle clicked? Handles live in the overlay layer.
945963
const handleEl = ev.target.closest('[data-handle]');
946964
const { x, y } = toCanvasCoords(ev);
@@ -971,6 +989,11 @@
971989
}
972990
canvasWrap.setPointerCapture(ev.pointerId);
973991
beginMove(x, y, ev.altKey);
992+
// Remember the layer we just clicked on. If pointerup happens
993+
// without movement AND this was already the only selected layer
994+
// AND it's a text layer, we'll promote the click to inline edit
995+
// (Canva's "click already-selected text again to type" gesture).
996+
state.drag.clickedOnText = (hit.kind === 'text' && state.selectedIds.size === 1 && state.selectedIds.has(hit.id));
974997
redraw();
975998
}
976999
function onPointerMove(ev) {
@@ -1018,12 +1041,26 @@
10181041
}
10191042
if (state.drag) {
10201043
const desc = state.drag.descriptor;
1044+
const clickedOnText = state.drag.clickedOnText;
10211045
// The drag mutated state directly to keep it cheap; we now
10221046
// wrap that in a single history entry by snapshotting the
10231047
// pre-drag state we squirreled away in drag.before.
10241048
const before = state.drag.before;
10251049
const after = snapshot();
1026-
if (JSON.stringify(before) !== JSON.stringify(after)) {
1050+
const moved = JSON.stringify(before) !== JSON.stringify(after);
1051+
// Click without drag on an already-selected text layer →
1052+
// promote to inline edit. Canva's gesture: first click
1053+
// selects, second (also a click, not double) starts editing.
1054+
if (!moved && clickedOnText && desc === 'move') {
1055+
const id = [...state.selectedIds][0];
1056+
const layer = state.template.layers.find((l) => l.id === id);
1057+
state.drag = null;
1058+
state.snapGuides = [];
1059+
try { canvasWrap.releasePointerCapture(ev.pointerId); } catch { /* */ }
1060+
if (layer) beginInlineTextEdit(layer);
1061+
return;
1062+
}
1063+
if (moved) {
10271064
history.past.push({ descriptor: desc, before, after });
10281065
if (history.past.length > history.capacity) history.past.shift();
10291066
history.future.length = 0;

0 commit comments

Comments
 (0)