mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 08:05:44 +00:00
* 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>
152 lines
4.9 KiB
TypeScript
152 lines
4.9 KiB
TypeScript
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
|