فهرست منبع

wip(app): line selection

Adam 1 ماه پیش
والد
کامیت
99e15caaf6
3فایلهای تغییر یافته به همراه115 افزوده شده و 31 حذف شده
  1. 63 10
      packages/ui/src/components/code.tsx
  2. 14 0
      packages/ui/src/components/session-review.css
  3. 38 21
      packages/ui/src/components/session-review.tsx

+ 63 - 10
packages/ui/src/components/code.tsx

@@ -1,5 +1,5 @@
 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 { getWorkerPool } from "../pierre/worker"
 
@@ -9,7 +9,9 @@ export type CodeProps<T = {}> = FileOptions<T> & {
   file: FileContents
   annotations?: LineAnnotation<T>[]
   selectedLines?: SelectedLineRange | null
+  commentedLines?: SelectedLineRange[]
   onRendered?: () => void
+  onLineSelectionEnd?: (selection: SelectedLineRange | null) => void
   class?: string
   classList?: ComponentProps<"div">["classList"]
 }
@@ -53,6 +55,8 @@ export function Code<T>(props: CodeProps<T>) {
   let dragStart: number | undefined
   let dragEnd: number | undefined
   let dragMoved = false
+  let lastSelection: SelectedLineRange | null = null
+  let pendingSelectionEnd = false
 
   const [local, others] = splitProps(props, [
     "file",
@@ -60,9 +64,13 @@ export function Code<T>(props: CodeProps<T>) {
     "classList",
     "annotations",
     "selectedLines",
+    "commentedLines",
     "onRendered",
+    "onLineSelectionEnd",
   ])
 
+  const [rendered, setRendered] = createSignal(0)
+
   const handleLineClick: FileOptions<T>["onLineClick"] = (info) => {
     props.onLineClick?.(info)
 
@@ -95,6 +103,30 @@ export function Code<T>(props: CodeProps<T>) {
     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 = () => {
     if (!local.onRendered) return
 
@@ -203,7 +235,12 @@ export function Code<T>(props: CodeProps<T>) {
     if (side) selected.side = side
     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 = () => {
@@ -212,6 +249,10 @@ export function Code<T>(props: CodeProps<T>) {
     selectionFrame = requestAnimationFrame(() => {
       selectionFrame = undefined
       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 end = Math.max(dragStart, dragEnd)
 
-    file().setSelectedLines({ start, end })
+    setSelectedLines({ start, end })
   }
 
   const scheduleDragUpdate = () => {
@@ -289,19 +330,22 @@ export function Code<T>(props: CodeProps<T>) {
 
   const handleMouseUp = () => {
     if (props.enableLineSelection !== true) return
+    if (dragStart === undefined) return
 
-    if (dragStart !== undefined) {
-      if (dragMoved) scheduleDragUpdate()
-      dragStart = undefined
-      dragEnd = undefined
-      dragMoved = false
+    if (dragMoved) {
+      pendingSelectionEnd = true
+      scheduleDragUpdate()
+      scheduleSelectionUpdate()
     }
 
-    scheduleSelectionUpdate()
+    dragStart = undefined
+    dragEnd = undefined
+    dragMoved = false
   }
 
   const handleSelectionChange = () => {
     if (props.enableLineSelection !== true) return
+    if (dragStart === undefined) return
 
     const selection = window.getSelection()
     if (!selection || selection.isCollapsed) return
@@ -328,11 +372,18 @@ export function Code<T>(props: CodeProps<T>) {
       containerWrapper: container,
     })
 
+    setRendered((value) => value + 1)
     notifyRendered()
   })
 
   createEffect(() => {
-    file().setSelectedLines(local.selectedLines ?? null)
+    rendered()
+    const ranges = local.commentedLines ?? []
+    requestAnimationFrame(() => applyCommentedLines(ranges))
+  })
+
+  createEffect(() => {
+    setSelectedLines(local.selectedLines ?? null)
   })
 
   createEffect(() => {
@@ -367,6 +418,8 @@ export function Code<T>(props: CodeProps<T>) {
     dragStart = undefined
     dragEnd = undefined
     dragMoved = false
+    lastSelection = null
+    pendingSelectionEnd = false
   })
 
   return (

+ 14 - 0
packages/ui/src/components/session-review.css

@@ -70,6 +70,20 @@
     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"] {
     display: flex;
     align-items: center;

+ 38 - 21
packages/ui/src/components/session-review.tsx

@@ -1,6 +1,5 @@
 import { Accordion } from "./accordion"
 import { Button } from "./button"
-import { HoverCard } from "./hover-card"
 import { Popover } from "./popover"
 import { RadioGroup } from "./radio-group"
 import { DiffChanges } from "./diff-changes"
@@ -151,6 +150,7 @@ function markerTop(wrapper: HTMLElement, marker: HTMLElement) {
 }
 
 export const SessionReview = (props: SessionReviewProps) => {
+  let scroll: HTMLDivElement | undefined
   const i18n = useI18n()
   const diffComponent = useDiffComponent()
   const anchors = new Map<string, HTMLElement>()
@@ -212,7 +212,29 @@ export const SessionReview = (props: SessionReviewProps) => {
     }
 
     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))
@@ -221,7 +243,10 @@ export const SessionReview = (props: SessionReviewProps) => {
   return (
     <div
       data-component="session-review"
-      ref={props.scrollRef}
+      ref={(el) => {
+        scroll = el
+        props.scrollRef?.(el)
+      }}
       onScroll={props.onScroll}
       classList={{
         ...(props.classList ?? {}),
@@ -574,6 +599,7 @@ export const SessionReview = (props: SessionReviewProps) => {
                             {(comment) => (
                               <div
                                 data-slot="session-review-comment-anchor"
+                                data-comment-id={comment.id}
                                 style={{
                                   top: `${positions()[comment.id] ?? 0}px`,
                                   opacity: positions()[comment.id] === undefined ? 0 : 1,
@@ -583,6 +609,7 @@ export const SessionReview = (props: SessionReviewProps) => {
                                 <Popover
                                   portal={false}
                                   open={isCommentOpen(comment)}
+                                  class="session-review-comment-popover-content"
                                   onOpenChange={(open) => {
                                     if (open) {
                                       openComment(comment)
@@ -592,26 +619,15 @@ export const SessionReview = (props: SessionReviewProps) => {
                                     setOpened(null)
                                   }}
                                   trigger={
-                                    <HoverCard
-                                      trigger={
-                                        <button
-                                          type="button"
-                                          data-slot="session-review-comment-button"
-                                          onMouseEnter={() =>
-                                            setSelection({ file: comment.file, range: comment.selection })
-                                          }
-                                        >
-                                          <Icon name="speech-bubble" size="small" />
-                                        </button>
+                                    <button
+                                      type="button"
+                                      data-slot="session-review-comment-button"
+                                      onMouseEnter={() =>
+                                        setSelection({ file: comment.file, range: comment.selection })
                                       }
                                     >
-                                      <div data-slot="session-review-comment-hover">
-                                        <div data-slot="session-review-comment-hover-label">
-                                          {getFilename(comment.file)}:{selectionLabel(comment.selection)}
-                                        </div>
-                                        <div data-slot="session-review-comment-hover-text">{comment.comment}</div>
-                                      </div>
-                                    </HoverCard>
+                                      <Icon name="speech-bubble" size="small" />
+                                    </button>
                                   }
                                 >
                                   <div data-slot="session-review-comment-popover">
@@ -635,6 +651,7 @@ export const SessionReview = (props: SessionReviewProps) => {
                                   <Popover
                                     portal={false}
                                     open={true}
+                                    class="session-review-comment-popover-content"
                                     onOpenChange={(open) => {
                                       if (open) return
                                       setCommenting(null)