diff options
| author | Adam <[email protected]> | 2026-01-04 15:40:25 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-01-22 22:12:12 -0600 |
| commit | 640d1f1ecc7a2b46fb2bafed760c7348c70579a8 (patch) | |
| tree | 090f22b0e98053e7089133f164b17cff0367daa6 /packages/ui/src/components/code.tsx | |
| parent | 2e53697da01d1417845567296774166350e786f1 (diff) | |
| download | opencode-640d1f1ecc7a2b46fb2bafed760c7348c70579a8.tar.gz opencode-640d1f1ecc7a2b46fb2bafed760c7348c70579a8.zip | |
wip(app): line selection
Diffstat (limited to 'packages/ui/src/components/code.tsx')
| -rw-r--r-- | packages/ui/src/components/code.tsx | 272 |
1 files changed, 254 insertions, 18 deletions
diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index ed7db368c..c6f702fb5 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -9,6 +9,7 @@ export type CodeProps<T = {}> = FileOptions<T> & { file: FileContents annotations?: LineAnnotation<T>[] selectedLines?: SelectedLineRange | null + onRendered?: () => void class?: string classList?: ComponentProps<"div">["classList"] } @@ -45,8 +46,32 @@ function findSide(node: Node | null): SelectionSide | undefined { export function Code<T>(props: CodeProps<T>) { let container!: HTMLDivElement + let observer: MutationObserver | undefined + let renderToken = 0 + let selectionFrame: number | undefined + let dragFrame: number | undefined + let dragStart: number | undefined + let dragEnd: number | undefined + let dragMoved = false + + const [local, others] = splitProps(props, [ + "file", + "class", + "classList", + "annotations", + "selectedLines", + "onRendered", + ]) + + const handleLineClick: FileOptions<T>["onLineClick"] = (info) => { + props.onLineClick?.(info) - const [local, others] = splitProps(props, ["file", "class", "classList", "annotations", "selectedLines"]) + if (props.enableLineSelection !== true) return + if (info.numberColumn) return + if (!local.selectedLines) return + + file().setSelectedLines(null) + } const file = createMemo( () => @@ -54,6 +79,7 @@ export function Code<T>(props: CodeProps<T>) { { ...createDefaultOptions<T>("unified"), ...others, + onLineClick: props.enableLineSelection === true || props.onLineClick ? handleLineClick : undefined, }, getWorkerPool("unified"), ), @@ -69,37 +95,218 @@ export function Code<T>(props: CodeProps<T>) { return root } - const handleMouseUp = () => { - if (props.enableLineSelection !== true) return + const notifyRendered = () => { + if (!local.onRendered) return + + observer?.disconnect() + observer = undefined + renderToken++ + + const token = renderToken + + const lines = (() => { + const text = local.file.contents + const total = text.split("\n").length - (text.endsWith("\n") ? 1 : 0) + return Math.max(1, total) + })() + + const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines + + const notify = () => { + if (token !== renderToken) return + + observer?.disconnect() + observer = undefined + requestAnimationFrame(() => { + if (token !== renderToken) return + local.onRendered?.() + }) + } + + const root = getRoot() + if (root && isReady(root)) { + notify() + return + } + + if (typeof MutationObserver === "undefined") return + + const observeRoot = (root: ShadowRoot) => { + if (isReady(root)) { + notify() + return + } + + observer?.disconnect() + observer = new MutationObserver(() => { + if (token !== renderToken) return + if (!isReady(root)) return + + notify() + }) + + observer.observe(root, { childList: true, subtree: true }) + } + + if (root) { + observeRoot(root) + return + } + + observer = new MutationObserver(() => { + if (token !== renderToken) return + + const root = getRoot() + if (!root) return + observeRoot(root) + }) + + observer.observe(container, { childList: true, subtree: true }) + } + + const updateSelection = () => { const root = getRoot() if (!root) return - const selection = window.getSelection() + const selection = + (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection() if (!selection || selection.isCollapsed) return - const anchor = selection.anchorNode - const focus = selection.focusNode - if (!anchor || !focus) return - if (!root.contains(anchor) || !root.contains(focus)) return + const domRange = + ( + selection as unknown as { + getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[] + } + ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ?? + (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined) + + const startNode = domRange?.startContainer ?? selection.anchorNode + const endNode = domRange?.endContainer ?? selection.focusNode + if (!startNode || !endNode) return - const start = findLineNumber(anchor) - const end = findLineNumber(focus) + if (!root.contains(startNode) || !root.contains(endNode)) return + + const start = findLineNumber(startNode) + const end = findLineNumber(endNode) if (start === undefined || end === undefined) return - const startSide = findSide(anchor) - const endSide = findSide(focus) + const startSide = findSide(startNode) + const endSide = findSide(endNode) const side = startSide ?? endSide - const range: SelectedLineRange = { + const selected: SelectedLineRange = { start, end, } - if (side) range.side = side - if (endSide && side && endSide !== side) range.endSide = endSide + if (side) selected.side = side + if (endSide && side && endSide !== side) selected.endSide = endSide + + file().setSelectedLines(selected) + } + + const scheduleSelectionUpdate = () => { + if (selectionFrame !== undefined) return + + selectionFrame = requestAnimationFrame(() => { + selectionFrame = undefined + updateSelection() + }) + } + + const updateDragSelection = () => { + if (dragStart === undefined || dragEnd === undefined) return + + const start = Math.min(dragStart, dragEnd) + const end = Math.max(dragStart, dragEnd) + + file().setSelectedLines({ start, end }) + } + + const scheduleDragUpdate = () => { + if (dragFrame !== undefined) return + + dragFrame = requestAnimationFrame(() => { + dragFrame = undefined + updateDragSelection() + }) + } + + const lineFromMouseEvent = (event: MouseEvent) => { + const path = event.composedPath() + + let numberColumn = false + let line: number | undefined + + for (const item of path) { + if (!(item instanceof HTMLElement)) continue + + numberColumn = numberColumn || item.dataset.columnNumber != null + + if (line === undefined && item.dataset.line) { + const parsed = parseInt(item.dataset.line, 10) + if (!Number.isNaN(parsed)) line = parsed + } + + if (numberColumn && line !== undefined) break + } + + return { line, numberColumn } + } + + const handleMouseDown = (event: MouseEvent) => { + if (props.enableLineSelection !== true) return + if (event.button !== 0) return + + const { line, numberColumn } = lineFromMouseEvent(event) + if (numberColumn) return + if (line === undefined) return + + dragStart = line + dragEnd = line + dragMoved = false + } + + const handleMouseMove = (event: MouseEvent) => { + if (props.enableLineSelection !== true) return + if (dragStart === undefined) return + + if ((event.buttons & 1) === 0) { + dragStart = undefined + dragEnd = undefined + dragMoved = false + return + } + + const { line } = lineFromMouseEvent(event) + if (line === undefined) return + + dragEnd = line + dragMoved = true + scheduleDragUpdate() + } + + const handleMouseUp = () => { + if (props.enableLineSelection !== true) return + + if (dragStart !== undefined) { + if (dragMoved) scheduleDragUpdate() + dragStart = undefined + dragEnd = undefined + dragMoved = false + } + + scheduleSelectionUpdate() + } + + const handleSelectionChange = () => { + if (props.enableLineSelection !== true) return + + const selection = window.getSelection() + if (!selection || selection.isCollapsed) return - file().setSelectedLines(range) + scheduleSelectionUpdate() } createEffect(() => { @@ -111,12 +318,17 @@ export function Code<T>(props: CodeProps<T>) { }) createEffect(() => { + observer?.disconnect() + observer = undefined + container.innerHTML = "" file().render({ file: local.file, lineAnnotations: local.annotations, containerWrapper: container, }) + + notifyRendered() }) createEffect(() => { @@ -126,13 +338,37 @@ export function Code<T>(props: CodeProps<T>) { createEffect(() => { if (props.enableLineSelection !== true) return - container.addEventListener("mouseup", handleMouseUp) + container.addEventListener("mousedown", handleMouseDown) + container.addEventListener("mousemove", handleMouseMove) + window.addEventListener("mouseup", handleMouseUp) + document.addEventListener("selectionchange", handleSelectionChange) onCleanup(() => { - container.removeEventListener("mouseup", handleMouseUp) + container.removeEventListener("mousedown", handleMouseDown) + container.removeEventListener("mousemove", handleMouseMove) + window.removeEventListener("mouseup", handleMouseUp) + document.removeEventListener("selectionchange", handleSelectionChange) }) }) + onCleanup(() => { + observer?.disconnect() + + if (selectionFrame !== undefined) { + cancelAnimationFrame(selectionFrame) + selectionFrame = undefined + } + + if (dragFrame !== undefined) { + cancelAnimationFrame(dragFrame) + dragFrame = undefined + } + + dragStart = undefined + dragEnd = undefined + dragMoved = false + }) + return ( <div data-component="code" |
