wip(app): line selection
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs"
|
import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs"
|
||||||
import { ComponentProps, createEffect, createMemo, onCleanup, splitProps } from "solid-js"
|
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js"
|
||||||
import { createDefaultOptions, styleVariables } from "../pierre"
|
import { createDefaultOptions, styleVariables } from "../pierre"
|
||||||
import { getWorkerPool } from "../pierre/worker"
|
import { getWorkerPool } from "../pierre/worker"
|
||||||
|
|
||||||
@@ -9,7 +9,9 @@ export type CodeProps<T = {}> = FileOptions<T> & {
|
|||||||
file: FileContents
|
file: FileContents
|
||||||
annotations?: LineAnnotation<T>[]
|
annotations?: LineAnnotation<T>[]
|
||||||
selectedLines?: SelectedLineRange | null
|
selectedLines?: SelectedLineRange | null
|
||||||
|
commentedLines?: SelectedLineRange[]
|
||||||
onRendered?: () => void
|
onRendered?: () => void
|
||||||
|
onLineSelectionEnd?: (selection: SelectedLineRange | null) => void
|
||||||
class?: string
|
class?: string
|
||||||
classList?: ComponentProps<"div">["classList"]
|
classList?: ComponentProps<"div">["classList"]
|
||||||
}
|
}
|
||||||
@@ -53,6 +55,8 @@ export function Code<T>(props: CodeProps<T>) {
|
|||||||
let dragStart: number | undefined
|
let dragStart: number | undefined
|
||||||
let dragEnd: number | undefined
|
let dragEnd: number | undefined
|
||||||
let dragMoved = false
|
let dragMoved = false
|
||||||
|
let lastSelection: SelectedLineRange | null = null
|
||||||
|
let pendingSelectionEnd = false
|
||||||
|
|
||||||
const [local, others] = splitProps(props, [
|
const [local, others] = splitProps(props, [
|
||||||
"file",
|
"file",
|
||||||
@@ -60,9 +64,13 @@ export function Code<T>(props: CodeProps<T>) {
|
|||||||
"classList",
|
"classList",
|
||||||
"annotations",
|
"annotations",
|
||||||
"selectedLines",
|
"selectedLines",
|
||||||
|
"commentedLines",
|
||||||
"onRendered",
|
"onRendered",
|
||||||
|
"onLineSelectionEnd",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
const [rendered, setRendered] = createSignal(0)
|
||||||
|
|
||||||
const handleLineClick: FileOptions<T>["onLineClick"] = (info) => {
|
const handleLineClick: FileOptions<T>["onLineClick"] = (info) => {
|
||||||
props.onLineClick?.(info)
|
props.onLineClick?.(info)
|
||||||
|
|
||||||
@@ -95,6 +103,30 @@ export function Code<T>(props: CodeProps<T>) {
|
|||||||
return root
|
return root
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const applyCommentedLines = (ranges: SelectedLineRange[]) => {
|
||||||
|
const root = getRoot()
|
||||||
|
if (!root) return
|
||||||
|
|
||||||
|
const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
|
||||||
|
for (const node of existing) {
|
||||||
|
if (!(node instanceof HTMLElement)) continue
|
||||||
|
node.removeAttribute("data-comment-selected")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
const start = Math.max(1, Math.min(range.start, range.end))
|
||||||
|
const end = Math.max(range.start, range.end)
|
||||||
|
|
||||||
|
for (let line = start; line <= end; line++) {
|
||||||
|
const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"]`))
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (!(node instanceof HTMLElement)) continue
|
||||||
|
node.setAttribute("data-comment-selected", "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const notifyRendered = () => {
|
const notifyRendered = () => {
|
||||||
if (!local.onRendered) return
|
if (!local.onRendered) return
|
||||||
|
|
||||||
@@ -203,7 +235,12 @@ export function Code<T>(props: CodeProps<T>) {
|
|||||||
if (side) selected.side = side
|
if (side) selected.side = side
|
||||||
if (endSide && side && endSide !== side) selected.endSide = endSide
|
if (endSide && side && endSide !== side) selected.endSide = endSide
|
||||||
|
|
||||||
file().setSelectedLines(selected)
|
setSelectedLines(selected)
|
||||||
|
}
|
||||||
|
|
||||||
|
const setSelectedLines = (range: SelectedLineRange | null) => {
|
||||||
|
lastSelection = range
|
||||||
|
file().setSelectedLines(range)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleSelectionUpdate = () => {
|
const scheduleSelectionUpdate = () => {
|
||||||
@@ -212,6 +249,10 @@ export function Code<T>(props: CodeProps<T>) {
|
|||||||
selectionFrame = requestAnimationFrame(() => {
|
selectionFrame = requestAnimationFrame(() => {
|
||||||
selectionFrame = undefined
|
selectionFrame = undefined
|
||||||
updateSelection()
|
updateSelection()
|
||||||
|
|
||||||
|
if (!pendingSelectionEnd) return
|
||||||
|
pendingSelectionEnd = false
|
||||||
|
props.onLineSelectionEnd?.(lastSelection)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,7 +262,7 @@ export function Code<T>(props: CodeProps<T>) {
|
|||||||
const start = Math.min(dragStart, dragEnd)
|
const start = Math.min(dragStart, dragEnd)
|
||||||
const end = Math.max(dragStart, dragEnd)
|
const end = Math.max(dragStart, dragEnd)
|
||||||
|
|
||||||
file().setSelectedLines({ start, end })
|
setSelectedLines({ start, end })
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduleDragUpdate = () => {
|
const scheduleDragUpdate = () => {
|
||||||
@@ -289,19 +330,22 @@ export function Code<T>(props: CodeProps<T>) {
|
|||||||
|
|
||||||
const handleMouseUp = () => {
|
const handleMouseUp = () => {
|
||||||
if (props.enableLineSelection !== true) return
|
if (props.enableLineSelection !== true) return
|
||||||
|
if (dragStart === undefined) return
|
||||||
|
|
||||||
if (dragStart !== undefined) {
|
if (dragMoved) {
|
||||||
if (dragMoved) scheduleDragUpdate()
|
pendingSelectionEnd = true
|
||||||
dragStart = undefined
|
scheduleDragUpdate()
|
||||||
dragEnd = undefined
|
scheduleSelectionUpdate()
|
||||||
dragMoved = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
scheduleSelectionUpdate()
|
dragStart = undefined
|
||||||
|
dragEnd = undefined
|
||||||
|
dragMoved = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelectionChange = () => {
|
const handleSelectionChange = () => {
|
||||||
if (props.enableLineSelection !== true) return
|
if (props.enableLineSelection !== true) return
|
||||||
|
if (dragStart === undefined) return
|
||||||
|
|
||||||
const selection = window.getSelection()
|
const selection = window.getSelection()
|
||||||
if (!selection || selection.isCollapsed) return
|
if (!selection || selection.isCollapsed) return
|
||||||
@@ -328,11 +372,18 @@ export function Code<T>(props: CodeProps<T>) {
|
|||||||
containerWrapper: container,
|
containerWrapper: container,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
setRendered((value) => value + 1)
|
||||||
notifyRendered()
|
notifyRendered()
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
file().setSelectedLines(local.selectedLines ?? null)
|
rendered()
|
||||||
|
const ranges = local.commentedLines ?? []
|
||||||
|
requestAnimationFrame(() => applyCommentedLines(ranges))
|
||||||
|
})
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
setSelectedLines(local.selectedLines ?? null)
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
@@ -367,6 +418,8 @@ export function Code<T>(props: CodeProps<T>) {
|
|||||||
dragStart = undefined
|
dragStart = undefined
|
||||||
dragEnd = undefined
|
dragEnd = undefined
|
||||||
dragMoved = false
|
dragMoved = false
|
||||||
|
lastSelection = null
|
||||||
|
pendingSelectionEnd = false
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -70,6 +70,20 @@
|
|||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[data-slot="session-review-accordion-content"] {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-component="popover-content"] {
|
||||||
|
position: absolute !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-review-comment-popover-content {
|
||||||
|
left: auto !important;
|
||||||
|
right: calc(100% + 12px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
[data-slot="session-review-trigger-content"] {
|
[data-slot="session-review-trigger-content"] {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Accordion } from "./accordion"
|
import { Accordion } from "./accordion"
|
||||||
import { Button } from "./button"
|
import { Button } from "./button"
|
||||||
import { HoverCard } from "./hover-card"
|
|
||||||
import { Popover } from "./popover"
|
import { Popover } from "./popover"
|
||||||
import { RadioGroup } from "./radio-group"
|
import { RadioGroup } from "./radio-group"
|
||||||
import { DiffChanges } from "./diff-changes"
|
import { DiffChanges } from "./diff-changes"
|
||||||
@@ -151,6 +150,7 @@ function markerTop(wrapper: HTMLElement, marker: HTMLElement) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const SessionReview = (props: SessionReviewProps) => {
|
export const SessionReview = (props: SessionReviewProps) => {
|
||||||
|
let scroll: HTMLDivElement | undefined
|
||||||
const i18n = useI18n()
|
const i18n = useI18n()
|
||||||
const diffComponent = useDiffComponent()
|
const diffComponent = useDiffComponent()
|
||||||
const anchors = new Map<string, HTMLElement>()
|
const anchors = new Map<string, HTMLElement>()
|
||||||
@@ -212,7 +212,29 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
anchors.get(focus.file)?.scrollIntoView({ block: "center" })
|
requestAnimationFrame(() => {
|
||||||
|
const root = scroll
|
||||||
|
if (!root) return
|
||||||
|
|
||||||
|
const anchor = root.querySelector(`[data-comment-id="${focus.id}"]`)
|
||||||
|
if (anchor instanceof HTMLElement) {
|
||||||
|
const rootRect = root.getBoundingClientRect()
|
||||||
|
const anchorRect = anchor.getBoundingClientRect()
|
||||||
|
const offset = anchorRect.top - rootRect.top
|
||||||
|
const next = root.scrollTop + offset - rootRect.height / 2 + anchorRect.height / 2
|
||||||
|
root.scrollTop = Math.max(0, next)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = anchors.get(focus.file)
|
||||||
|
if (!target) return
|
||||||
|
|
||||||
|
const rootRect = root.getBoundingClientRect()
|
||||||
|
const targetRect = target.getBoundingClientRect()
|
||||||
|
const offset = targetRect.top - rootRect.top
|
||||||
|
const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2
|
||||||
|
root.scrollTop = Math.max(0, next)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
requestAnimationFrame(() => props.onFocusedCommentChange?.(null))
|
requestAnimationFrame(() => props.onFocusedCommentChange?.(null))
|
||||||
@@ -221,7 +243,10 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-component="session-review"
|
data-component="session-review"
|
||||||
ref={props.scrollRef}
|
ref={(el) => {
|
||||||
|
scroll = el
|
||||||
|
props.scrollRef?.(el)
|
||||||
|
}}
|
||||||
onScroll={props.onScroll}
|
onScroll={props.onScroll}
|
||||||
classList={{
|
classList={{
|
||||||
...(props.classList ?? {}),
|
...(props.classList ?? {}),
|
||||||
@@ -574,6 +599,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
{(comment) => (
|
{(comment) => (
|
||||||
<div
|
<div
|
||||||
data-slot="session-review-comment-anchor"
|
data-slot="session-review-comment-anchor"
|
||||||
|
data-comment-id={comment.id}
|
||||||
style={{
|
style={{
|
||||||
top: `${positions()[comment.id] ?? 0}px`,
|
top: `${positions()[comment.id] ?? 0}px`,
|
||||||
opacity: positions()[comment.id] === undefined ? 0 : 1,
|
opacity: positions()[comment.id] === undefined ? 0 : 1,
|
||||||
@@ -583,6 +609,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
<Popover
|
<Popover
|
||||||
portal={false}
|
portal={false}
|
||||||
open={isCommentOpen(comment)}
|
open={isCommentOpen(comment)}
|
||||||
|
class="session-review-comment-popover-content"
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
openComment(comment)
|
openComment(comment)
|
||||||
@@ -592,26 +619,15 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
setOpened(null)
|
setOpened(null)
|
||||||
}}
|
}}
|
||||||
trigger={
|
trigger={
|
||||||
<HoverCard
|
<button
|
||||||
trigger={
|
type="button"
|
||||||
<button
|
data-slot="session-review-comment-button"
|
||||||
type="button"
|
onMouseEnter={() =>
|
||||||
data-slot="session-review-comment-button"
|
setSelection({ file: comment.file, range: comment.selection })
|
||||||
onMouseEnter={() =>
|
|
||||||
setSelection({ file: comment.file, range: comment.selection })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Icon name="speech-bubble" size="small" />
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div data-slot="session-review-comment-hover">
|
<Icon name="speech-bubble" size="small" />
|
||||||
<div data-slot="session-review-comment-hover-label">
|
</button>
|
||||||
{getFilename(comment.file)}:{selectionLabel(comment.selection)}
|
|
||||||
</div>
|
|
||||||
<div data-slot="session-review-comment-hover-text">{comment.comment}</div>
|
|
||||||
</div>
|
|
||||||
</HoverCard>
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div data-slot="session-review-comment-popover">
|
<div data-slot="session-review-comment-popover">
|
||||||
@@ -635,6 +651,7 @@ export const SessionReview = (props: SessionReviewProps) => {
|
|||||||
<Popover
|
<Popover
|
||||||
portal={false}
|
portal={false}
|
||||||
open={true}
|
open={true}
|
||||||
|
class="session-review-comment-popover-content"
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (open) return
|
if (open) return
|
||||||
setCommenting(null)
|
setCommenting(null)
|
||||||
|
|||||||
Reference in New Issue
Block a user