Kanban: card text cut off when longer than one line #52
Labels
No labels
prio_critical
prio_low
type_bug
type_contact
type_issue
type_lead
type_question
type_story
type_task
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
lhumina_code/hero_whiteboard#52
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
Card text longer than one line does not display — cards use
wrap: 'none', ellipsis: true, which truncates to a single line with ellipsis. Additionally, the inline editor is a single-line<input>, so multi-line content cannot even be entered via the UI.Evidence
renderCard(): cardText iswrap: 'none', ellipsis: true.editInline(): always usesdocument.createElement('input')withtype='text'.Fix
wrap: 'word', dropellipsis: true.cardText.height()and set the card slot height accordingly; propagate through the card slot Y offsets so cards stack without overlapping.editInlinewith a<textarea>when the target is a card text node (keep single-line input for titles/column names). Shift+Enter inserts a newline, Enter or blur commits; Escape cancels.Implementation Spec for Issue #52
Objective
Fix kanban card text rendering so multi-line content displays correctly and allow multi-line entry in the inline editor. Card text must word-wrap instead of truncating with an ellipsis, the card slot height must grow per-card to accommodate wrapped text, and double-click/Edit must open a multi-line textarea (Shift+Enter = newline, Enter/blur = commit, Escape = cancel). Base
cardHeightin state (controlled by slider + transformer resize) continues to act as the minimum slot height; wrapped text can push a given card taller without persisting that overflow into state.Requirements
wrap: 'word',ellipsis: false(drop theellipsis: truefromrenderCard). Text width stays atcolW - 50(preserves room for the 3-dots menu).max(baseCardHeight, measuredWrappedTextHeight + verticalPadding).cardH). This flows through:renderColumn/renderCard,renderKanbantotalH),dragenddrop-slot-index search.editInlinegains amultilineparameter. Whentrue, renders a<textarea>that grows vertically, supportsShift+Enterfor newlines, commits onEnterand on blur, cancels onEscape. Single-line path for titles/column names is unchanged.card.textmay contain\ncharacters. No schema change: Konva.Text renders explicit\n; JSON round-trips\nlosslessly.#007AFFstroke inline without re-rendering (preserves Konva dblclick identity).st.cardHeightfromsy. Unchanged; the value becomes the base only.showCardMenualready receivescardText; multi-lineeditInline(..., true)must be invoked from both the dblclick handler and the menu Edit item.textFsstill derives from the basecardH.Files to Modify/Create
Modify only:
crates/hero_whiteboard_ui/static/web/js/whiteboard/kanban.jsNo changes to sync.js, tools.js, properties.js, shortcuts.js, or CSS.
Implementation Plan
Step 1 — Introduce height-measurement helper and constants
Files:
kanban.jscardGap:var cardTextPadV = 16;(8px padding top + 8px padding bottom).measureCardTextHeight(text, fontSize, width)that constructs a temporaryKonva.Text({ text, fontSize, width, wrap: 'word' }), reads.height(), destroys the temp node, returns the height.var _colLayout = new WeakMap();to hold per-column{heights, offsets}between measurement and render.Dependencies: none.
Step 2 — Two-pass measurement in
renderKanbanFiles:
kanban.js, functionrenderKanbanbg, iterate columns and compute each column's{heights, offsets}:textFs = clampFont(cardH * 0.25, 9, 20)(same formula asrenderCard).heights[i] = max(cardH, measureCardTextHeight(card.text || 'Untitled', textFs, colW - 50) + cardTextPadV).offsets[i] = sum(heights[0..i-1] + cardGap)._colLayout.set(col, { heights, offsets }).stackH = heights.length ? offsets[last] + heights[last] : 0.totalH = headerH + 8 + max(maxStack, cardH) + 28 + 50.Dependencies: Step 1.
Step 3 —
renderColumnreads pre-computed layoutFiles:
kanban.js, functionrenderColumnvar layout = _colLayout.get(col) || { heights: [], offsets: [] };.col.cards.forEach(function(card, cardIdx) { renderCard(group, col, colIdx, card, cardIdx, cx, cy, columns, colW, cardH, layout.offsets[cardIdx], layout.heights[cardIdx]); });addYcalc with the stack-end from the layout:var stackEnd = layout.heights.length ? layout.offsets[last] + layout.heights[last] + cardGap : 0; var addY = cy + 28 + stackEnd;Dependencies: Step 2.
Step 4 — Adapt
renderCardsignature to receive Y and heightFiles:
kanban.js, functionrenderCardrenderCard(group, col, colIdx, card, cardIdx, cx, cy, columns, colW, baseCardH, cardYLocal, actualCardH).var cardY = cy + 28 + cardYLocal;baseCardHunchanged:textFs,menuFs.textY = 8unconditionally (top-align; wrap-enabled text makes center layout impractical).menuYcomputed against a top-anchored band:var menuBandH = Math.min(actualCardH, 40); var menuY = Math.round((menuBandH - menuFs) / 2);cardRectusesheight: actualCardH.cardText:wrap: 'word', dropellipsis: true. Width stayscolW - 50.menuHit:y: 2, height: actualCardH - 4.Dependencies: Steps 2, 3.
Step 5 — Fix
cardGroup.on('dragend')slot calculationFiles:
kanban.jstargetColIdx:var layout = _colLayout.get(cols[targetColIdx]);.for (j = 0; j < targetCards.length; j++) { var midY = headerH + 28 + layout.offsets[j] + layout.heights[j] / 2; if (local.y < midY) { insertIdx = j; break; } }.Dependencies: Steps 2, 3.
Step 6 — Add
multilineparam toeditInline, wire up card callersFiles:
kanban.jseditInline(group, textNode, onDone, initialValue, multiline).!multiline: existing<input type="text">path unchanged.multiline:<textarea class="konva-text-edit">.resize:none; line-height:1.3; overflow:hidden; white-space:pre-wrap;.max(textNode.height() * stageScale, textNode.fontSize() * stageScale + 12).inputevent:this.style.height = 'auto'; this.style.height = this.scrollHeight + 'px';.Enter→preventDefault()+blur()(commit);Shift+Enter→ default (newline) + trigger auto-grow;Escape→ restore initial + blur.e.stopPropagation()always.multiline: true:cardRect.on('dblclick dbltap')handler.showCardMenuEdit item.Dependencies: none (independent of Steps 1–5).
Acceptance Criteria
cardHeight; wrapped text reflows ontransformend.Notes
(a) Variable slot heights.
state.cardHeight= base/minimum slot height; per-card actual =max(base, measuredText + cardTextPadV). Only base is persisted; measurements re-derive each render.(b) Stack layout. Two-pass
renderKanban: measure all columns → computetotalHfrom max stack → render columns reading the stored layout.heights/offsetslive in a module-levelWeakMap<col, layout>, never serialized.(c) Drag-drop.
cardGroup.on('dragend')reads the target column's layout from the WeakMap and uses per-card midpoints. Defensive fallback to uniform formula if absent.(d) menuBtn positioning on tall cards. 3-dots stays anchored near the TOP, centered within a 40px band (or
actualCardHif smaller). Prevents the button from drifting into the middle of wrapped text. Tradeoff: for very tall cards the menu is not vertically centered — but the alternative collides with text.(e) editInline multiline. Flag is
trueonly for card text. Textarea auto-grows on input; plain Enter commits (preventDefault to avoid a stray newline before blur); Shift+Enter inserts a newline; Escape cancels. Existing.konva-text-editCSS is reused.(f) Font and resize interaction.
textFsuses the basecardH— PR #51 behavior intact. Per-card actual heights absorb wrap overflow beyond the base. Transformer live-redraw (tools.js) writes the basecardHeight; downstreamrenderKanbanre-measures and re-stacks.(g) Cross-PR compatibility.
cardRect.on('click tap')still mutatesstroke/strokeWidthdirectly without a full render.cardHeightas the scalar; the new reader-side interpretation ("base") is transparent.showCardMenualready receivescardText; Edit call gainsmultiline: true.Scope discipline. All changes in
kanban.js. No schema change. No new module. No reshuffle of responsibilities beyond threading per-card Y/height throughrenderColumn→renderCardand centralizing measurement inrenderKanban.Test Results
JavaScript-only change in
kanban.js; Rust workspace validated for regressions.cargo check --workspacecargo test --workspace --libcargo clippy --workspace -- -D warningscargo fmt --checkNo Rust tests exercise the JavaScript whiteboard modules, so the suite confirms regression safety on the Rust side. Manual verification against a running UI is required for the wrap/stack/drag/edit criteria listed in the spec's Acceptance Criteria.
Implementation Summary
Kanban cards now wrap long text, grow to fit, and accept multi-line input.
Files changed
crates/hero_whiteboard_ui/static/web/js/whiteboard/kanban.js(+93 / -25)Behavior changes
cardTextuseswrap: 'word',ellipsisremoved. Long card text wraps across multiple lines.max(baseCardHeight, measuredWrappedTextHeight + cardTextPadV). BasecardHeightin state remains the slider/transformer-controlled minimum; wrapped overflow is never persisted.renderKanbandoes a two-pass measurement: for every column it computes{heights[], offsets[]}and stores the layout in a module-levelWeakMap<col, layout>. ColumntotalHis derived from the tallest column stack.renderColumnandrenderCardread the layout back throughWeakMap.get.renderCardnow receivescardYLocalandactualCardHfrom its caller;cardRect.heightandmenuHit.heightuse the per-card actual height.cardGroup.on('dragend')computes drop-slot midpoints asheaderH + 28 + offsets[j] + heights[j]/2from the target column's layout, so reordering works correctly for variable-height cards. Defensive fallback to the uniform formula if the WeakMap lookup misses.menuBtnis top-anchored inside a 40-px band so the 3-dots stay at the visible top-right of tall cards rather than drifting into the middle of wrapped text.editInlinegained amultilineparameter. Whentrueit renders a<textarea>with line-height, auto-grow oninput,Shift+Enternewlines,Enter/blurcommit,Escapecancel. Single-line<input>path for board-title and column-title edits is preserved unchanged. The dblclick handler oncardRectand the menu Edit item both passmultiline: true.Preserved
textFs/menuFsstill derive from basecardH.cardRect.on('click tap')still draws its own 2px#007AFFstroke inline without re-rendering, so Konva dblclick-identity is preserved.state.cardHeightfrom the livesy; downstreamrenderKanbanre-measures and re-stacks.showCardMenustill receivescardTextas a direct reference; the Edit call now passesmultiline: true.Out of scope
card.textremains a plain string;\nround-trips through JSON.shortcuts.js,tools.js,properties.js, or CSS. The whiteboard's global key handler already short-circuits onINPUT/TEXTAREAtargets, so Delete/Backspace/Enter shortcuts don't fire while typing.Test results
cargo check --workspace: passcargo test --workspace --lib: passcargo clippy --workspace -- -D warnings: passcargo fmt --check: passManual QA required: wrap rendering, stacking under tall cards, drag-drop into variable-height columns, textarea editing (Enter commit, Shift+Enter newline, Escape cancel), persistence across reload, and all preserved behaviors (click-select, drag-as-a-unit, font scaling, transformer resize, menu Edit).
Pull request opened: #66
This PR implements the changes discussed in this issue.