Files
windmill/frontend/src/lib/autosize.ts
T
GuilhemandClaude Opus 4.8 365e20410e feat(ai-chat): collapse big pastes, cap input height, escape HTML (#9487)
* feat(ai-chat): collapse big pastes, cap input height, escape HTML

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(ai-chat): concentrate paste expand/render in a ChatDraft module

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(ai-chat): step caret over paste chips as one unit

Arrow-Left/Right now jump edge-to-edge across a collapsed-paste chip
instead of crawling through the invisible token characters, and snap the
caret out if it lands inside a token. Shift extends the selection across
the whole chip; word/line jumps (alt/cmd/ctrl) are left to the browser.
Mirrors the existing atomic-deletion behavior so traversal and deletion
both treat the chip as a single object.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): atomic chip deletion on overlapping selections + plural label

A selection that partially overlapped a paste token previously hit the
collapsed-caret early-return, so the browser deleted a partial token and
left an orphaned zero-width run plus a dangling pastes registry entry.
handlePasteDeletion now widens the deletion range to cover each overlapped
token whole and drops all affected pastes entries (shared removePasteRange
helper). Strict overlap, so abutting a chip edge doesn't pull it in.

Also route line-count pluralization through a shared lineCount() helper so
a 1-line paste reads "1 line" in the conversation bubble too, not "1 lines".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): full chip atomicity via beforeinput + expand on copy/cut

Addresses review nits on PR #9487:

- Chip atomicity is no longer limited to Backspace/Delete keydown. A new
  beforeinput handler takes over any selection-spanning edit that overlaps
  a paste chip — typing over a selection, paste, drag-and-drop, and
  Backspace/Delete over a selection — and applies it to the whole token(s)
  via a shared replacePasteRange, so a partial edit can no longer leave an
  orphaned zero-width run or a dangling pastes registry entry. handlePaste
  is likewise widened so a large paste onto a chip replaces it whole. The
  keydown handler now only covers the collapsed-caret-at-boundary case
  beforeinput can't see.
- Copy/cut of a selection containing a chip now puts the expanded content
  on the clipboard instead of the chip label + its zero-width run; cut also
  removes the chip whole. This also removes the duplicate-token re-paste
  vector (the clipboard never carries a raw token anymore).
- Rename lineCount -> lineCountLabel: it returns a formatted string, not a
  count, so the name shouldn't read like an accessor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(ai-chat): address cubic + claude review findings on the paste feature

cubic-dev-ai findings:
- autosize: clear inline overflow-y on the uncapped path so a capped->
  uncapped toggle can't leave a stale `auto`/`hidden`.
- pasteTokens: new countLines() ignores a single trailing newline, so a
  10-line paste with a trailing \n no longer counts as 11 (off-by-one in
  shouldCollapsePaste and the chip's reported line count).
- ContextTextarea: the @-mention picker anchor now subtracts the textarea's
  own scrollTop/Left and is recomputed on internal scroll, so it stays
  pinned once the input is capped at 40vh and scrolls.

claude re-review findings:
- ContextTextarea: drag-and-drop of a selection containing a chip now puts
  the expanded content on the drag payload via ondragstart (mirroring
  copy/cut), so a dragged chip no longer orphans its token and loses the
  pasted content (or leaks the label to an external target).
- ContextTextarea: handlePasteBeforeInput now guards on e.cancelable and a
  HANDLED_INPUT_TYPES whitelist, so historyUndo/Redo (Ctrl+Z) and
  non-cancelable insertCompositionText (IME) are left to the browser
  instead of being reinterpreted as a delete / rewritten mid-composition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 14:58:01 +00:00

152 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { tick } from 'svelte'
type TextArea = HTMLTextAreaElement
/**
* Optional parameters for the `autosize` action.
*
* `maxHeight` caps how tall the textarea may grow. Once the content exceeds it,
* the textarea stops growing and scrolls internally (overflow-y: auto) instead.
* Accepts a number (px) or a CSS-ish string ending in `vh`/`px` (e.g. `'40vh'`).
* When omitted the textarea grows without bound (the historical behaviour).
*/
export type AutosizeParams = { maxHeight?: number | string } | undefined
/** Resolve a `maxHeight` param to a pixel value, or null when uncapped/invalid. */
function resolveMaxHeight(maxHeight: number | string | undefined): number | null {
if (maxHeight == null) return null
if (typeof maxHeight === 'number') return maxHeight
const s = maxHeight.trim()
const v = parseFloat(s)
if (isNaN(v)) return null
if (s.endsWith('vh')) return (v / 100) * window.innerHeight
return v // 'px' or bare number → pixels
}
export const autosize = (node: TextArea, params?: AutosizeParams) => {
/* ------------------------------------------------------------------
* Constants
* ---------------------------------------------------------------- */
const UPDATE_EVENT = new Event('update')
const MIN_HEIGHT = 30 // px
const EXTRA = 2 // px added to scrollHeight
let width = 0
let maxHeight = params?.maxHeight
let capped = maxHeight != null
/* ------------------------------------------------------------------
* Core resize routine
* ---------------------------------------------------------------- */
const resize = () => {
node.style.height = 'auto'
let height = Math.max(node.scrollHeight, MIN_HEIGHT) + EXTRA
const maxPx = resolveMaxHeight(maxHeight)
if (maxPx != null) {
if (height > maxPx) {
height = maxPx
node.style.overflowY = 'auto'
} else {
node.style.overflowY = 'hidden'
}
} else {
// Uncapped — including after a capped→uncapped toggle: drop any inline
// overflow we set while capped so the textarea returns to its default
// (class-driven) behaviour rather than keeping a stale `auto`/`hidden`.
node.style.overflowY = ''
}
node.style.height = `${height}px`
}
/* ------------------------------------------------------------------
* Patch `value` so programmatic changes trigger resize
* ---------------------------------------------------------------- */
const proto = Object.getPrototypeOf(node)
const desc = Object.getOwnPropertyDescriptor(proto, 'value')
if (desc) {
Object.defineProperty(node, 'value', {
get() {
return desc.get?.call(this)
},
set(v: unknown) {
desc.set?.call(this, v)
node.dispatchEvent(UPDATE_EVENT)
}
})
}
/* ------------------------------------------------------------------
* Event listeners
* ---------------------------------------------------------------- */
const onInput = () => node.dispatchEvent(UPDATE_EVENT)
node.addEventListener('input', onInput)
node.addEventListener('update', resize)
// A `vh`-based cap depends on the viewport height, so recompute on window
// resize. Only attached when a cap is configured to avoid adding listeners
// for the many uncapped textareas across the app.
if (capped) {
window.addEventListener('resize', resize)
}
/* ------------------------------------------------------------------
* Inline styling
* ---------------------------------------------------------------- */
node.style.boxSizing = 'border-box'
/* ------------------------------------------------------------------
* Wait for DOM mount, then do an initial measure.
* If the <textarea> is already visible (offsetWidth > 0) this covers it;
* otherwise the first ResizeObserver callback will.
* ---------------------------------------------------------------- */
;(async () => {
await tick()
resize()
})()
/* ------------------------------------------------------------------
* ResizeObserver handles:
* • first time the element gets a real width
* • container/window resizes afterwards
* ---------------------------------------------------------------- */
const ro = new ResizeObserver(([entry]) => {
const newWidth = entry.contentRect.width
if (newWidth !== width) {
width = newWidth
resize()
}
})
ro.observe(node)
/* ------------------------------------------------------------------
* Action lifecycle
* ---------------------------------------------------------------- */
return {
update(newParams?: AutosizeParams) {
maxHeight = newParams?.maxHeight
const nowCapped = maxHeight != null
if (nowCapped && !capped) {
window.addEventListener('resize', resize)
} else if (!nowCapped && capped) {
window.removeEventListener('resize', resize)
}
capped = nowCapped
resize()
},
destroy() {
ro.disconnect()
node.removeEventListener('input', onInput)
node.removeEventListener('update', resize)
if (capped) {
window.removeEventListener('resize', resize)
}
}
}
}
export default autosize