mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-08 01:39:12 +00:00
feat(app): better diff/code comments (#14621)
Co-authored-by: adamelmore <2363879+adamdottv@users.noreply.github.com> Co-authored-by: David Hill <iamdavidhill@gmail.com>
This commit is contained in:
74
packages/ui/src/pierre/comment-hover.ts
Normal file
74
packages/ui/src/pierre/comment-hover.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
export type HoverCommentLine = {
|
||||
lineNumber: number
|
||||
side?: "additions" | "deletions"
|
||||
}
|
||||
|
||||
export function createHoverCommentUtility(props: {
|
||||
label: string
|
||||
getHoveredLine: () => HoverCommentLine | undefined
|
||||
onSelect: (line: HoverCommentLine) => void
|
||||
}) {
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const button = document.createElement("button")
|
||||
button.type = "button"
|
||||
button.ariaLabel = props.label
|
||||
button.textContent = "+"
|
||||
button.style.width = "20px"
|
||||
button.style.height = "20px"
|
||||
button.style.display = "flex"
|
||||
button.style.alignItems = "center"
|
||||
button.style.justifyContent = "center"
|
||||
button.style.border = "none"
|
||||
button.style.borderRadius = "var(--radius-md)"
|
||||
button.style.background = "var(--icon-interactive-base)"
|
||||
button.style.color = "var(--white)"
|
||||
button.style.boxShadow = "var(--shadow-xs)"
|
||||
button.style.fontSize = "14px"
|
||||
button.style.lineHeight = "1"
|
||||
button.style.cursor = "pointer"
|
||||
button.style.position = "relative"
|
||||
button.style.left = "30px"
|
||||
button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
|
||||
|
||||
let line: HoverCommentLine | undefined
|
||||
|
||||
const sync = () => {
|
||||
const next = props.getHoveredLine()
|
||||
if (!next) return
|
||||
line = next
|
||||
}
|
||||
|
||||
const loop = () => {
|
||||
if (!button.isConnected) return
|
||||
sync()
|
||||
requestAnimationFrame(loop)
|
||||
}
|
||||
|
||||
const open = () => {
|
||||
const next = props.getHoveredLine() ?? line
|
||||
if (!next) return
|
||||
props.onSelect(next)
|
||||
}
|
||||
|
||||
requestAnimationFrame(loop)
|
||||
button.addEventListener("mouseenter", sync)
|
||||
button.addEventListener("mousemove", sync)
|
||||
button.addEventListener("pointerdown", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
sync()
|
||||
})
|
||||
button.addEventListener("mousedown", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
sync()
|
||||
})
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
open()
|
||||
})
|
||||
|
||||
return button
|
||||
}
|
||||
91
packages/ui/src/pierre/commented-lines.ts
Normal file
91
packages/ui/src/pierre/commented-lines.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { diffLineIndex, diffRowIndex, findDiffSide } from "./diff-selection"
|
||||
|
||||
export type CommentSide = "additions" | "deletions"
|
||||
|
||||
function annotationIndex(node: HTMLElement) {
|
||||
const value = node.dataset.lineAnnotation?.split(",")[1]
|
||||
if (!value) return
|
||||
const line = parseInt(value, 10)
|
||||
if (Number.isNaN(line)) return
|
||||
return line
|
||||
}
|
||||
|
||||
function clear(root: ShadowRoot) {
|
||||
const marked = Array.from(root.querySelectorAll("[data-comment-selected]"))
|
||||
for (const node of marked) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.removeAttribute("data-comment-selected")
|
||||
}
|
||||
}
|
||||
|
||||
export function markCommentedDiffLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
|
||||
clear(root)
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
const rows = Array.from(diffs.querySelectorAll("[data-line-index]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const annotations = Array.from(diffs.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const range of ranges) {
|
||||
const start = diffRowIndex(root, split, range.start, range.side as CommentSide | undefined)
|
||||
if (start === undefined) continue
|
||||
|
||||
const end = (() => {
|
||||
const same = range.end === range.start && (range.endSide == null || range.endSide === range.side)
|
||||
if (same) return start
|
||||
return diffRowIndex(root, split, range.end, (range.endSide ?? range.side) as CommentSide | undefined)
|
||||
})()
|
||||
if (end === undefined) continue
|
||||
|
||||
const first = Math.min(start, end)
|
||||
const last = Math.max(start, end)
|
||||
|
||||
for (const row of rows) {
|
||||
const idx = diffLineIndex(split, row)
|
||||
if (idx === undefined || idx < first || idx > last) continue
|
||||
row.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const idx = annotationIndex(annotation)
|
||||
if (idx === undefined || idx < first || idx > last) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function markCommentedFileLines(root: ShadowRoot, ranges: SelectedLineRange[]) {
|
||||
clear(root)
|
||||
|
||||
const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
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}"], [data-column-number="${line}"]`))
|
||||
for (const node of nodes) {
|
||||
if (!(node instanceof HTMLElement)) continue
|
||||
node.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
|
||||
for (const annotation of annotations) {
|
||||
const line = annotationIndex(annotation)
|
||||
if (line === undefined || line < start || line > end) continue
|
||||
annotation.setAttribute("data-comment-selected", "")
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/ui/src/pierre/diff-selection.ts
Normal file
71
packages/ui/src/pierre/diff-selection.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
|
||||
export type DiffSelectionSide = "additions" | "deletions"
|
||||
|
||||
export function findDiffSide(node: HTMLElement): DiffSelectionSide {
|
||||
const line = node.closest("[data-line], [data-alt-line]")
|
||||
if (line instanceof HTMLElement) {
|
||||
const type = line.dataset.lineType
|
||||
if (type === "change-deletion") return "deletions"
|
||||
if (type === "change-addition" || type === "change-additions") return "additions"
|
||||
}
|
||||
|
||||
const code = node.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return "additions"
|
||||
return code.hasAttribute("data-deletions") ? "deletions" : "additions"
|
||||
}
|
||||
|
||||
export function diffLineIndex(split: boolean, node: HTMLElement) {
|
||||
const raw = node.dataset.lineIndex
|
||||
if (!raw) return
|
||||
|
||||
const values = raw
|
||||
.split(",")
|
||||
.map((x) => parseInt(x, 10))
|
||||
.filter((x) => !Number.isNaN(x))
|
||||
if (values.length === 0) return
|
||||
if (!split) return values[0]
|
||||
if (values.length === 2) return values[1]
|
||||
return values[0]
|
||||
}
|
||||
|
||||
export function diffRowIndex(root: ShadowRoot, split: boolean, line: number, side: DiffSelectionSide | undefined) {
|
||||
const rows = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
if (rows.length === 0) return
|
||||
|
||||
const target = side ?? "additions"
|
||||
for (const row of rows) {
|
||||
if (findDiffSide(row) === target) return diffLineIndex(split, row)
|
||||
if (parseInt(row.dataset.altLine ?? "", 10) === line) return diffLineIndex(split, row)
|
||||
}
|
||||
}
|
||||
|
||||
export function fixDiffSelection(root: ShadowRoot | undefined, range: SelectedLineRange | null) {
|
||||
if (!range) return range
|
||||
if (!root) return
|
||||
|
||||
const diffs = root.querySelector("[data-diff]")
|
||||
if (!(diffs instanceof HTMLElement)) return
|
||||
|
||||
const split = diffs.dataset.diffType === "split"
|
||||
const start = diffRowIndex(root, split, range.start, range.side)
|
||||
const end = diffRowIndex(root, split, range.end, range.endSide ?? range.side)
|
||||
|
||||
if (start === undefined || end === undefined) {
|
||||
if (root.querySelector("[data-line], [data-alt-line]") == null) return
|
||||
return null
|
||||
}
|
||||
if (start <= end) return range
|
||||
|
||||
const side = range.endSide ?? range.side
|
||||
const swapped: SelectedLineRange = {
|
||||
start: range.end,
|
||||
end: range.start,
|
||||
}
|
||||
|
||||
if (side) swapped.side = side
|
||||
if (range.endSide && range.side) swapped.endSide = range.side
|
||||
return swapped
|
||||
}
|
||||
576
packages/ui/src/pierre/file-find.ts
Normal file
576
packages/ui/src/pierre/file-find.ts
Normal file
@@ -0,0 +1,576 @@
|
||||
import { createEffect, createSignal, onCleanup, onMount } from "solid-js"
|
||||
|
||||
export type FindHost = {
|
||||
element: () => HTMLElement | undefined
|
||||
open: () => void
|
||||
close: () => void
|
||||
next: (dir: 1 | -1) => void
|
||||
isOpen: () => boolean
|
||||
}
|
||||
|
||||
type FileFindSide = "additions" | "deletions"
|
||||
|
||||
export type FileFindReveal = {
|
||||
side: FileFindSide
|
||||
line: number
|
||||
col: number
|
||||
len: number
|
||||
}
|
||||
|
||||
type FileFindHit = FileFindReveal & {
|
||||
range: Range
|
||||
alt?: number
|
||||
}
|
||||
|
||||
const hosts = new Set<FindHost>()
|
||||
let target: FindHost | undefined
|
||||
let current: FindHost | undefined
|
||||
let installed = false
|
||||
|
||||
function isEditable(node: unknown): boolean {
|
||||
if (!(node instanceof HTMLElement)) return false
|
||||
if (node.closest("[data-prevent-autofocus]")) return true
|
||||
if (node.isContentEditable) return true
|
||||
return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName)
|
||||
}
|
||||
|
||||
function hostForNode(node: unknown) {
|
||||
if (!(node instanceof Node)) return
|
||||
for (const host of hosts) {
|
||||
const el = host.element()
|
||||
if (el && el.isConnected && el.contains(node)) return host
|
||||
}
|
||||
}
|
||||
|
||||
function installShortcuts() {
|
||||
if (installed) return
|
||||
if (typeof window === "undefined") return
|
||||
installed = true
|
||||
|
||||
window.addEventListener(
|
||||
"keydown",
|
||||
(event) => {
|
||||
if (event.defaultPrevented) return
|
||||
if (isEditable(event.target)) return
|
||||
|
||||
const mod = event.metaKey || event.ctrlKey
|
||||
if (!mod) return
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
if (key === "g") {
|
||||
const host = current
|
||||
if (!host || !host.isOpen()) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
host.next(event.shiftKey ? -1 : 1)
|
||||
return
|
||||
}
|
||||
|
||||
if (key !== "f") return
|
||||
|
||||
const active = current
|
||||
if (active && active.isOpen()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
active.open()
|
||||
return
|
||||
}
|
||||
|
||||
const host = hostForNode(document.activeElement) ?? hostForNode(event.target) ?? target ?? Array.from(hosts)[0]
|
||||
if (!host) return
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
host.open()
|
||||
},
|
||||
{ capture: true },
|
||||
)
|
||||
}
|
||||
|
||||
function clearHighlightFind() {
|
||||
const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights
|
||||
if (!api) return
|
||||
api.delete("opencode-find")
|
||||
api.delete("opencode-find-current")
|
||||
}
|
||||
|
||||
function supportsHighlights() {
|
||||
const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown }
|
||||
return typeof g.Highlight === "function" && g.CSS?.highlights != null
|
||||
}
|
||||
|
||||
function scrollParent(el: HTMLElement): HTMLElement | undefined {
|
||||
let parent = el.parentElement
|
||||
while (parent) {
|
||||
const style = getComputedStyle(parent)
|
||||
if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
|
||||
parent = parent.parentElement
|
||||
}
|
||||
}
|
||||
|
||||
type CreateFileFindOptions = {
|
||||
wrapper: () => HTMLElement | undefined
|
||||
overlay: () => HTMLDivElement | undefined
|
||||
getRoot: () => ShadowRoot | undefined
|
||||
shortcuts?: "global" | "disabled"
|
||||
}
|
||||
|
||||
export function createFileFind(opts: CreateFileFindOptions) {
|
||||
let input: HTMLInputElement | undefined
|
||||
let overlayFrame: number | undefined
|
||||
let overlayScroll: HTMLElement[] = []
|
||||
let mode: "highlights" | "overlay" = "overlay"
|
||||
let hits: FileFindHit[] = []
|
||||
|
||||
const [open, setOpen] = createSignal(false)
|
||||
const [query, setQuery] = createSignal("")
|
||||
const [index, setIndex] = createSignal(0)
|
||||
const [count, setCount] = createSignal(0)
|
||||
const [pos, setPos] = createSignal({ top: 8, right: 8 })
|
||||
|
||||
const clearOverlayScroll = () => {
|
||||
for (const el of overlayScroll) el.removeEventListener("scroll", scheduleOverlay)
|
||||
overlayScroll = []
|
||||
}
|
||||
|
||||
const clearOverlay = () => {
|
||||
const el = opts.overlay()
|
||||
if (!el) return
|
||||
if (overlayFrame !== undefined) {
|
||||
cancelAnimationFrame(overlayFrame)
|
||||
overlayFrame = undefined
|
||||
}
|
||||
el.innerHTML = ""
|
||||
}
|
||||
|
||||
const renderOverlay = () => {
|
||||
if (mode !== "overlay") {
|
||||
clearOverlay()
|
||||
return
|
||||
}
|
||||
|
||||
const wrapper = opts.wrapper()
|
||||
const overlay = opts.overlay()
|
||||
if (!wrapper || !overlay) return
|
||||
|
||||
clearOverlay()
|
||||
if (hits.length === 0) return
|
||||
|
||||
const base = wrapper.getBoundingClientRect()
|
||||
const currentIndex = index()
|
||||
const frag = document.createDocumentFragment()
|
||||
|
||||
for (let i = 0; i < hits.length; i++) {
|
||||
const range = hits[i].range
|
||||
const active = i === currentIndex
|
||||
for (const rect of Array.from(range.getClientRects())) {
|
||||
if (!rect.width || !rect.height) continue
|
||||
|
||||
const mark = document.createElement("div")
|
||||
mark.style.position = "absolute"
|
||||
mark.style.left = `${Math.round(rect.left - base.left)}px`
|
||||
mark.style.top = `${Math.round(rect.top - base.top)}px`
|
||||
mark.style.width = `${Math.round(rect.width)}px`
|
||||
mark.style.height = `${Math.round(rect.height)}px`
|
||||
mark.style.borderRadius = "2px"
|
||||
mark.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)"
|
||||
mark.style.opacity = active ? "0.55" : "0.35"
|
||||
if (active) mark.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)"
|
||||
frag.appendChild(mark)
|
||||
}
|
||||
}
|
||||
|
||||
overlay.appendChild(frag)
|
||||
}
|
||||
|
||||
function scheduleOverlay() {
|
||||
if (mode !== "overlay") return
|
||||
if (!open()) return
|
||||
if (overlayFrame !== undefined) return
|
||||
|
||||
overlayFrame = requestAnimationFrame(() => {
|
||||
overlayFrame = undefined
|
||||
renderOverlay()
|
||||
})
|
||||
}
|
||||
|
||||
const syncOverlayScroll = () => {
|
||||
if (mode !== "overlay") return
|
||||
const root = opts.getRoot()
|
||||
|
||||
const next = root
|
||||
? Array.from(root.querySelectorAll("[data-code]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
: []
|
||||
if (next.length === overlayScroll.length && next.every((el, i) => el === overlayScroll[i])) return
|
||||
|
||||
clearOverlayScroll()
|
||||
overlayScroll = next
|
||||
for (const el of overlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true })
|
||||
}
|
||||
|
||||
const clearFind = () => {
|
||||
clearHighlightFind()
|
||||
clearOverlay()
|
||||
clearOverlayScroll()
|
||||
hits = []
|
||||
setCount(0)
|
||||
setIndex(0)
|
||||
}
|
||||
|
||||
const positionBar = () => {
|
||||
if (typeof window === "undefined") return
|
||||
const wrapper = opts.wrapper()
|
||||
if (!wrapper) return
|
||||
|
||||
const root = scrollParent(wrapper) ?? wrapper
|
||||
const rect = root.getBoundingClientRect()
|
||||
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
|
||||
const header = Number.isNaN(title) ? 0 : title
|
||||
|
||||
setPos({
|
||||
top: Math.round(rect.top) + header - 4,
|
||||
right: Math.round(window.innerWidth - rect.right) + 8,
|
||||
})
|
||||
}
|
||||
|
||||
const scan = (root: ShadowRoot, value: string) => {
|
||||
const needle = value.toLowerCase()
|
||||
const ranges: FileFindHit[] = []
|
||||
const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter(
|
||||
(node): node is HTMLElement => node instanceof HTMLElement,
|
||||
)
|
||||
|
||||
for (const col of cols) {
|
||||
const text = col.textContent
|
||||
if (!text) continue
|
||||
|
||||
const hay = text.toLowerCase()
|
||||
let at = hay.indexOf(needle)
|
||||
if (at === -1) continue
|
||||
|
||||
const row = col.closest("[data-line], [data-alt-line]")
|
||||
if (!(row instanceof HTMLElement)) continue
|
||||
|
||||
const primary = parseInt(row.dataset.line ?? "", 10)
|
||||
const alt = parseInt(row.dataset.altLine ?? "", 10)
|
||||
const line = (() => {
|
||||
if (!Number.isNaN(primary)) return primary
|
||||
if (!Number.isNaN(alt)) return alt
|
||||
})()
|
||||
if (line === undefined) continue
|
||||
|
||||
const side = (() => {
|
||||
const code = col.closest("[data-code]")
|
||||
if (code instanceof HTMLElement) return code.hasAttribute("data-deletions") ? "deletions" : "additions"
|
||||
|
||||
const row = col.closest("[data-line-type]")
|
||||
if (!(row instanceof HTMLElement)) return "additions"
|
||||
const type = row.dataset.lineType
|
||||
if (type === "change-deletion") return "deletions"
|
||||
return "additions"
|
||||
})() as FileFindSide
|
||||
|
||||
const nodes: Text[] = []
|
||||
const ends: number[] = []
|
||||
const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
|
||||
let node = walker.nextNode()
|
||||
let pos = 0
|
||||
while (node) {
|
||||
if (node instanceof Text) {
|
||||
pos += node.data.length
|
||||
nodes.push(node)
|
||||
ends.push(pos)
|
||||
}
|
||||
node = walker.nextNode()
|
||||
}
|
||||
if (nodes.length === 0) continue
|
||||
|
||||
const locate = (offset: number) => {
|
||||
let lo = 0
|
||||
let hi = ends.length - 1
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (ends[mid] >= offset) hi = mid
|
||||
else lo = mid + 1
|
||||
}
|
||||
const prev = lo === 0 ? 0 : ends[lo - 1]
|
||||
return { node: nodes[lo], offset: offset - prev }
|
||||
}
|
||||
|
||||
while (at !== -1) {
|
||||
const start = locate(at)
|
||||
const end = locate(at + value.length)
|
||||
const range = document.createRange()
|
||||
range.setStart(start.node, start.offset)
|
||||
range.setEnd(end.node, end.offset)
|
||||
ranges.push({
|
||||
range,
|
||||
side,
|
||||
line,
|
||||
alt: Number.isNaN(alt) ? undefined : alt,
|
||||
col: at + 1,
|
||||
len: value.length,
|
||||
})
|
||||
at = hay.indexOf(needle, at + value.length)
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const scrollToRange = (range: Range) => {
|
||||
const scroll = () => {
|
||||
const start = range.startContainer
|
||||
const el = start instanceof Element ? start : start.parentElement
|
||||
el?.scrollIntoView({ block: "center", inline: "center" })
|
||||
}
|
||||
|
||||
scroll()
|
||||
requestAnimationFrame(scroll)
|
||||
}
|
||||
|
||||
const setHighlights = (ranges: FileFindHit[], currentIndex: number) => {
|
||||
const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
|
||||
const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
|
||||
if (!api || typeof Highlight !== "function") return false
|
||||
|
||||
api.delete("opencode-find")
|
||||
api.delete("opencode-find-current")
|
||||
|
||||
const active = ranges[currentIndex]?.range
|
||||
if (active) api.set("opencode-find-current", new Highlight(active))
|
||||
|
||||
const rest = ranges.flatMap((hit, i) => (i === currentIndex ? [] : [hit.range]))
|
||||
if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
|
||||
return true
|
||||
}
|
||||
|
||||
const select = (currentIndex: number, scroll: boolean) => {
|
||||
const active = hits[currentIndex]?.range
|
||||
if (!active) return false
|
||||
|
||||
setIndex(currentIndex)
|
||||
|
||||
if (mode === "highlights") {
|
||||
if (!setHighlights(hits, currentIndex)) {
|
||||
mode = "overlay"
|
||||
apply({ reset: true, scroll })
|
||||
return false
|
||||
}
|
||||
if (scroll) scrollToRange(active)
|
||||
return true
|
||||
}
|
||||
|
||||
clearHighlightFind()
|
||||
syncOverlayScroll()
|
||||
if (scroll) scrollToRange(active)
|
||||
scheduleOverlay()
|
||||
return true
|
||||
}
|
||||
|
||||
const apply = (args?: { reset?: boolean; scroll?: boolean }) => {
|
||||
if (!open()) return
|
||||
|
||||
const value = query().trim()
|
||||
if (!value) {
|
||||
clearFind()
|
||||
return
|
||||
}
|
||||
|
||||
const root = opts.getRoot()
|
||||
if (!root) return
|
||||
|
||||
mode = supportsHighlights() ? "highlights" : "overlay"
|
||||
|
||||
const ranges = scan(root, value)
|
||||
const total = ranges.length
|
||||
const desired = args?.reset ? 0 : index()
|
||||
const currentIndex = total ? Math.min(desired, total - 1) : 0
|
||||
|
||||
hits = ranges
|
||||
setCount(total)
|
||||
setIndex(currentIndex)
|
||||
|
||||
const active = ranges[currentIndex]?.range
|
||||
if (mode === "highlights") {
|
||||
clearOverlay()
|
||||
clearOverlayScroll()
|
||||
if (!setHighlights(ranges, currentIndex)) {
|
||||
mode = "overlay"
|
||||
clearHighlightFind()
|
||||
syncOverlayScroll()
|
||||
scheduleOverlay()
|
||||
}
|
||||
if (args?.scroll && active) scrollToRange(active)
|
||||
return
|
||||
}
|
||||
|
||||
clearHighlightFind()
|
||||
syncOverlayScroll()
|
||||
if (args?.scroll && active) scrollToRange(active)
|
||||
scheduleOverlay()
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
setOpen(false)
|
||||
setQuery("")
|
||||
clearFind()
|
||||
if (current === host) current = undefined
|
||||
}
|
||||
|
||||
const clear = () => {
|
||||
setQuery("")
|
||||
clearFind()
|
||||
}
|
||||
|
||||
const activate = () => {
|
||||
if (opts.shortcuts !== "disabled") {
|
||||
if (current && current !== host) current.close()
|
||||
current = host
|
||||
target = host
|
||||
}
|
||||
|
||||
if (!open()) setOpen(true)
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
activate()
|
||||
requestAnimationFrame(() => {
|
||||
apply({ scroll: true })
|
||||
input?.focus()
|
||||
input?.select()
|
||||
})
|
||||
}
|
||||
|
||||
const next = (dir: 1 | -1) => {
|
||||
if (!open()) return
|
||||
const total = count()
|
||||
if (total <= 0) return
|
||||
|
||||
const currentIndex = (index() + dir + total) % total
|
||||
select(currentIndex, true)
|
||||
}
|
||||
|
||||
const reveal = (targetHit: FileFindReveal) => {
|
||||
if (!open()) return false
|
||||
if (hits.length === 0) return false
|
||||
|
||||
const exact = hits.findIndex(
|
||||
(hit) =>
|
||||
hit.side === targetHit.side &&
|
||||
hit.line === targetHit.line &&
|
||||
hit.col === targetHit.col &&
|
||||
hit.len === targetHit.len,
|
||||
)
|
||||
const fallback = hits.findIndex(
|
||||
(hit) =>
|
||||
(hit.line === targetHit.line || hit.alt === targetHit.line) &&
|
||||
hit.col === targetHit.col &&
|
||||
hit.len === targetHit.len,
|
||||
)
|
||||
|
||||
const nextIndex = exact >= 0 ? exact : fallback
|
||||
if (nextIndex < 0) return false
|
||||
return select(nextIndex, true)
|
||||
}
|
||||
|
||||
const host: FindHost = {
|
||||
element: opts.wrapper,
|
||||
isOpen: () => open(),
|
||||
next,
|
||||
open: focus,
|
||||
close,
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mode = supportsHighlights() ? "highlights" : "overlay"
|
||||
if (opts.shortcuts !== "disabled") {
|
||||
installShortcuts()
|
||||
hosts.add(host)
|
||||
if (!target) target = host
|
||||
}
|
||||
|
||||
onCleanup(() => {
|
||||
if (opts.shortcuts !== "disabled") {
|
||||
hosts.delete(host)
|
||||
if (current === host) {
|
||||
current = undefined
|
||||
clearHighlightFind()
|
||||
}
|
||||
if (target === host) target = undefined
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!open()) return
|
||||
|
||||
const update = () => positionBar()
|
||||
requestAnimationFrame(update)
|
||||
window.addEventListener("resize", update, { passive: true })
|
||||
|
||||
const wrapper = opts.wrapper()
|
||||
if (!wrapper) return
|
||||
const root = scrollParent(wrapper) ?? wrapper
|
||||
const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update())
|
||||
observer?.observe(root)
|
||||
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("resize", update)
|
||||
observer?.disconnect()
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
clearOverlayScroll()
|
||||
clearOverlay()
|
||||
if (current === host) {
|
||||
current = undefined
|
||||
clearHighlightFind()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
open,
|
||||
query,
|
||||
count,
|
||||
index,
|
||||
pos,
|
||||
setInput: (el: HTMLInputElement) => {
|
||||
input = el
|
||||
},
|
||||
setQuery: (value: string, args?: { scroll?: boolean }) => {
|
||||
setQuery(value)
|
||||
setIndex(0)
|
||||
apply({ reset: true, scroll: args?.scroll ?? true })
|
||||
},
|
||||
clear,
|
||||
activate,
|
||||
focus,
|
||||
close,
|
||||
next,
|
||||
reveal,
|
||||
refresh: (args?: { reset?: boolean; scroll?: boolean }) => apply(args),
|
||||
onPointerDown: () => {
|
||||
if (opts.shortcuts === "disabled") return
|
||||
target = host
|
||||
opts.wrapper()?.focus({ preventScroll: true })
|
||||
},
|
||||
onFocus: () => {
|
||||
if (opts.shortcuts === "disabled") return
|
||||
target = host
|
||||
},
|
||||
onInputKeyDown: (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
close()
|
||||
return
|
||||
}
|
||||
if (event.key !== "Enter") return
|
||||
event.preventDefault()
|
||||
next(event.shiftKey ? -1 : 1)
|
||||
},
|
||||
}
|
||||
}
|
||||
114
packages/ui/src/pierre/file-runtime.ts
Normal file
114
packages/ui/src/pierre/file-runtime.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
type ReadyWatcher = {
|
||||
observer?: MutationObserver
|
||||
token: number
|
||||
}
|
||||
|
||||
export function createReadyWatcher(): ReadyWatcher {
|
||||
return { token: 0 }
|
||||
}
|
||||
|
||||
export function clearReadyWatcher(state: ReadyWatcher) {
|
||||
state.observer?.disconnect()
|
||||
state.observer = undefined
|
||||
}
|
||||
|
||||
export function getViewerHost(container: HTMLElement | undefined) {
|
||||
if (!container) return
|
||||
const host = container.querySelector("diffs-container")
|
||||
if (!(host instanceof HTMLElement)) return
|
||||
return host
|
||||
}
|
||||
|
||||
export function getViewerRoot(container: HTMLElement | undefined) {
|
||||
return getViewerHost(container)?.shadowRoot ?? undefined
|
||||
}
|
||||
|
||||
export function applyViewerScheme(host: HTMLElement | undefined) {
|
||||
if (!host) return
|
||||
if (typeof document === "undefined") return
|
||||
|
||||
const scheme = document.documentElement.dataset.colorScheme
|
||||
if (scheme === "dark" || scheme === "light") {
|
||||
host.dataset.colorScheme = scheme
|
||||
return
|
||||
}
|
||||
|
||||
host.removeAttribute("data-color-scheme")
|
||||
}
|
||||
|
||||
export function observeViewerScheme(getHost: () => HTMLElement | undefined) {
|
||||
if (typeof document === "undefined") return () => {}
|
||||
|
||||
applyViewerScheme(getHost())
|
||||
if (typeof MutationObserver === "undefined") return () => {}
|
||||
|
||||
const root = document.documentElement
|
||||
const monitor = new MutationObserver(() => applyViewerScheme(getHost()))
|
||||
monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
|
||||
return () => monitor.disconnect()
|
||||
}
|
||||
|
||||
export function notifyShadowReady(opts: {
|
||||
state: ReadyWatcher
|
||||
container: HTMLElement
|
||||
getRoot: () => ShadowRoot | undefined
|
||||
isReady: (root: ShadowRoot) => boolean
|
||||
onReady: () => void
|
||||
settleFrames?: number
|
||||
}) {
|
||||
clearReadyWatcher(opts.state)
|
||||
opts.state.token += 1
|
||||
|
||||
const token = opts.state.token
|
||||
const settle = Math.max(0, opts.settleFrames ?? 0)
|
||||
|
||||
const runReady = () => {
|
||||
const step = (left: number) => {
|
||||
if (token !== opts.state.token) return
|
||||
if (left <= 0) {
|
||||
opts.onReady()
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(() => step(left - 1))
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => step(settle))
|
||||
}
|
||||
|
||||
const observeRoot = (root: ShadowRoot) => {
|
||||
if (opts.isReady(root)) {
|
||||
runReady()
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof MutationObserver === "undefined") return
|
||||
|
||||
clearReadyWatcher(opts.state)
|
||||
opts.state.observer = new MutationObserver(() => {
|
||||
if (token !== opts.state.token) return
|
||||
if (!opts.isReady(root)) return
|
||||
|
||||
clearReadyWatcher(opts.state)
|
||||
runReady()
|
||||
})
|
||||
opts.state.observer.observe(root, { childList: true, subtree: true })
|
||||
}
|
||||
|
||||
const root = opts.getRoot()
|
||||
if (!root) {
|
||||
if (typeof MutationObserver === "undefined") return
|
||||
|
||||
opts.state.observer = new MutationObserver(() => {
|
||||
if (token !== opts.state.token) return
|
||||
|
||||
const next = opts.getRoot()
|
||||
if (!next) return
|
||||
|
||||
observeRoot(next)
|
||||
})
|
||||
opts.state.observer.observe(opts.container, { childList: true, subtree: true })
|
||||
return
|
||||
}
|
||||
|
||||
observeRoot(root)
|
||||
}
|
||||
85
packages/ui/src/pierre/file-selection.ts
Normal file
85
packages/ui/src/pierre/file-selection.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
import { toRange } from "./selection-bridge"
|
||||
|
||||
export function findElement(node: Node | null): HTMLElement | undefined {
|
||||
if (!node) return
|
||||
if (node instanceof HTMLElement) return node
|
||||
return node.parentElement ?? undefined
|
||||
}
|
||||
|
||||
export function findFileLineNumber(node: Node | null): number | undefined {
|
||||
const el = findElement(node)
|
||||
if (!el) return
|
||||
|
||||
const line = el.closest("[data-line]")
|
||||
if (!(line instanceof HTMLElement)) return
|
||||
|
||||
const value = parseInt(line.dataset.line ?? "", 10)
|
||||
if (Number.isNaN(value)) return
|
||||
return value
|
||||
}
|
||||
|
||||
export function findDiffLineNumber(node: Node | null): number | undefined {
|
||||
const el = findElement(node)
|
||||
if (!el) return
|
||||
|
||||
const line = el.closest("[data-line], [data-alt-line]")
|
||||
if (!(line instanceof HTMLElement)) return
|
||||
|
||||
const primary = parseInt(line.dataset.line ?? "", 10)
|
||||
if (!Number.isNaN(primary)) return primary
|
||||
|
||||
const alt = parseInt(line.dataset.altLine ?? "", 10)
|
||||
if (!Number.isNaN(alt)) return alt
|
||||
}
|
||||
|
||||
export function findCodeSelectionSide(node: Node | null): SelectedLineRange["side"] {
|
||||
const el = findElement(node)
|
||||
if (!el) return
|
||||
|
||||
const code = el.closest("[data-code]")
|
||||
if (!(code instanceof HTMLElement)) return
|
||||
if (code.hasAttribute("data-deletions")) return "deletions"
|
||||
return "additions"
|
||||
}
|
||||
|
||||
export function readShadowLineSelection(opts: {
|
||||
root: ShadowRoot
|
||||
lineForNode: (node: Node | null) => number | undefined
|
||||
sideForNode?: (node: Node | null) => SelectedLineRange["side"]
|
||||
preserveTextSelection?: boolean
|
||||
}) {
|
||||
const selection =
|
||||
(opts.root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
|
||||
if (!selection || selection.isCollapsed) return
|
||||
|
||||
const domRange =
|
||||
(
|
||||
selection as unknown as {
|
||||
getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => StaticRange[]
|
||||
}
|
||||
).getComposedRanges?.({ shadowRoots: [opts.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
|
||||
if (!opts.root.contains(startNode) || !opts.root.contains(endNode)) return
|
||||
|
||||
const start = opts.lineForNode(startNode)
|
||||
const end = opts.lineForNode(endNode)
|
||||
if (start === undefined || end === undefined) return
|
||||
|
||||
const startSide = opts.sideForNode?.(startNode)
|
||||
const endSide = opts.sideForNode?.(endNode)
|
||||
const side = startSide ?? endSide
|
||||
|
||||
const range: SelectedLineRange = { start, end }
|
||||
if (side) range.side = side
|
||||
if (endSide && side && endSide !== side) range.endSide = endSide
|
||||
|
||||
return {
|
||||
range,
|
||||
text: opts.preserveTextSelection && domRange ? toRange(domRange).cloneRange() : undefined,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DiffLineAnnotation, FileContents, FileDiffOptions, type SelectedLineRange } from "@pierre/diffs"
|
||||
import { ComponentProps } from "solid-js"
|
||||
import { lineCommentStyles } from "../components/line-comment-styles"
|
||||
|
||||
export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
||||
before: FileContents
|
||||
@@ -7,13 +8,15 @@ export type DiffProps<T = {}> = FileDiffOptions<T> & {
|
||||
annotations?: DiffLineAnnotation<T>[]
|
||||
selectedLines?: SelectedLineRange | null
|
||||
commentedLines?: SelectedLineRange[]
|
||||
onLineNumberSelectionEnd?: (selection: SelectedLineRange | null) => void
|
||||
onRendered?: () => void
|
||||
class?: string
|
||||
classList?: ComponentProps<"div">["classList"]
|
||||
}
|
||||
|
||||
const unsafeCSS = `
|
||||
[data-diff] {
|
||||
[data-diff],
|
||||
[data-file] {
|
||||
--diffs-bg: light-dark(var(--diffs-light-bg), var(--diffs-dark-bg));
|
||||
--diffs-bg-buffer: var(--diffs-bg-buffer-override, light-dark( color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 92%, var(--diffs-mixer))));
|
||||
--diffs-bg-hover: var(--diffs-bg-hover-override, light-dark( color-mix(in lab, var(--diffs-bg) 97%, var(--diffs-mixer)), color-mix(in lab, var(--diffs-bg) 91%, var(--diffs-mixer))));
|
||||
@@ -44,7 +47,8 @@ const unsafeCSS = `
|
||||
--diffs-bg-selection-text: rgb(from var(--surface-warning-strong) r g b / 0.2);
|
||||
}
|
||||
|
||||
:host([data-color-scheme='dark']) [data-diff] {
|
||||
:host([data-color-scheme='dark']) [data-diff],
|
||||
:host([data-color-scheme='dark']) [data-file] {
|
||||
--diffs-selection-number-fg: #fdfbfb;
|
||||
--diffs-bg-selection: var(--diffs-bg-selection-override, rgb(from var(--solaris-dark-6) r g b / 0.65));
|
||||
--diffs-bg-selection-number: var(
|
||||
@@ -53,7 +57,8 @@ const unsafeCSS = `
|
||||
);
|
||||
}
|
||||
|
||||
[data-diff] ::selection {
|
||||
[data-diff] ::selection,
|
||||
[data-file] ::selection {
|
||||
background-color: var(--diffs-bg-selection-text);
|
||||
}
|
||||
|
||||
@@ -69,25 +74,48 @@ const unsafeCSS = `
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-file] [data-line][data-comment-selected]:not([data-selected-line]) {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-diff] [data-column-number][data-comment-selected]:not([data-selected-line]) {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-file] [data-column-number][data-comment-selected]:not([data-selected-line]) {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-diff] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-file] [data-line-annotation][data-comment-selected]:not([data-selected-line]) [data-annotation-content] {
|
||||
box-shadow: inset 0 0 0 9999px var(--diffs-bg-selection);
|
||||
}
|
||||
|
||||
[data-diff] [data-line][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection);
|
||||
box-shadow: inset 2px 0 0 var(--diffs-selection-border);
|
||||
}
|
||||
|
||||
[data-file] [data-line][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection);
|
||||
box-shadow: inset 2px 0 0 var(--diffs-selection-border);
|
||||
}
|
||||
|
||||
[data-diff] [data-column-number][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-file] [data-column-number][data-selected-line] {
|
||||
background-color: var(--diffs-bg-selection-number);
|
||||
color: var(--diffs-selection-number-fg);
|
||||
}
|
||||
|
||||
[data-diff] [data-column-number][data-line-type='context'][data-selected-line],
|
||||
[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line],
|
||||
[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
|
||||
@@ -123,9 +151,13 @@ const unsafeCSS = `
|
||||
}
|
||||
[data-code] {
|
||||
overflow-x: auto !important;
|
||||
overflow-y: hidden !important;
|
||||
overflow-y: clip !important;
|
||||
}
|
||||
}`
|
||||
}
|
||||
|
||||
${lineCommentStyles}
|
||||
|
||||
`
|
||||
|
||||
export function createDefaultOptions<T>(style: FileDiffOptions<T>["diffStyle"]) {
|
||||
return {
|
||||
|
||||
110
packages/ui/src/pierre/media.ts
Normal file
110
packages/ui/src/pierre/media.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { FileContent } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type MediaKind = "image" | "audio" | "svg"
|
||||
|
||||
const imageExtensions = new Set(["png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "tif", "tiff", "heic"])
|
||||
const audioExtensions = new Set(["mp3", "wav", "ogg", "m4a", "aac", "flac", "opus"])
|
||||
|
||||
type MediaValue = unknown
|
||||
|
||||
function mediaRecord(value: unknown) {
|
||||
if (!value || typeof value !== "object") return
|
||||
return value as Partial<FileContent> & {
|
||||
content?: unknown
|
||||
encoding?: unknown
|
||||
mimeType?: unknown
|
||||
type?: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeMimeType(type: string | undefined) {
|
||||
if (!type) return
|
||||
const mime = type.split(";", 1)[0]?.trim().toLowerCase()
|
||||
if (!mime) return
|
||||
if (mime === "audio/x-aac") return "audio/aac"
|
||||
if (mime === "audio/x-m4a") return "audio/mp4"
|
||||
return mime
|
||||
}
|
||||
|
||||
export function fileExtension(path: string | undefined) {
|
||||
if (!path) return ""
|
||||
const idx = path.lastIndexOf(".")
|
||||
if (idx === -1) return ""
|
||||
return path.slice(idx + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export function mediaKindFromPath(path: string | undefined): MediaKind | undefined {
|
||||
const ext = fileExtension(path)
|
||||
if (ext === "svg") return "svg"
|
||||
if (imageExtensions.has(ext)) return "image"
|
||||
if (audioExtensions.has(ext)) return "audio"
|
||||
}
|
||||
|
||||
export function isBinaryContent(value: MediaValue) {
|
||||
return mediaRecord(value)?.type === "binary"
|
||||
}
|
||||
|
||||
function validDataUrl(value: string, kind: MediaKind) {
|
||||
if (kind === "svg") return value.startsWith("data:image/svg+xml") ? value : undefined
|
||||
if (kind === "image") return value.startsWith("data:image/") ? value : undefined
|
||||
if (value.startsWith("data:audio/x-aac;")) return value.replace("data:audio/x-aac;", "data:audio/aac;")
|
||||
if (value.startsWith("data:audio/x-m4a;")) return value.replace("data:audio/x-m4a;", "data:audio/mp4;")
|
||||
if (value.startsWith("data:audio/")) return value
|
||||
}
|
||||
|
||||
export function dataUrlFromMediaValue(value: MediaValue, kind: MediaKind) {
|
||||
if (!value) return
|
||||
|
||||
if (typeof value === "string") {
|
||||
return validDataUrl(value, kind)
|
||||
}
|
||||
|
||||
const record = mediaRecord(value)
|
||||
if (!record) return
|
||||
|
||||
if (typeof record.content !== "string") return
|
||||
|
||||
const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
|
||||
if (!mime) return
|
||||
|
||||
if (kind === "svg") {
|
||||
if (mime !== "image/svg+xml") return
|
||||
if (record.encoding === "base64") return `data:image/svg+xml;base64,${record.content}`
|
||||
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(record.content)}`
|
||||
}
|
||||
|
||||
if (kind === "image" && !mime.startsWith("image/")) return
|
||||
if (kind === "audio" && !mime.startsWith("audio/")) return
|
||||
if (record.encoding !== "base64") return
|
||||
|
||||
return `data:${mime};base64,${record.content}`
|
||||
}
|
||||
|
||||
function decodeBase64Utf8(value: string) {
|
||||
if (typeof atob !== "function") return
|
||||
|
||||
try {
|
||||
const raw = atob(value)
|
||||
const bytes = Uint8Array.from(raw, (x) => x.charCodeAt(0))
|
||||
if (typeof TextDecoder === "function") return new TextDecoder().decode(bytes)
|
||||
return raw
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function svgTextFromValue(value: MediaValue) {
|
||||
const record = mediaRecord(value)
|
||||
if (!record) return
|
||||
if (typeof record.content !== "string") return
|
||||
|
||||
const mime = normalizeMimeType(typeof record.mimeType === "string" ? record.mimeType : undefined)
|
||||
if (mime !== "image/svg+xml") return
|
||||
if (record.encoding === "base64") return decodeBase64Utf8(record.content)
|
||||
return record.content
|
||||
}
|
||||
|
||||
export function hasMediaValue(value: MediaValue) {
|
||||
if (typeof value === "string") return value.length > 0
|
||||
const record = mediaRecord(value)
|
||||
if (!record) return false
|
||||
return typeof record.content === "string" && record.content.length > 0
|
||||
}
|
||||
129
packages/ui/src/pierre/selection-bridge.ts
Normal file
129
packages/ui/src/pierre/selection-bridge.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { type SelectedLineRange } from "@pierre/diffs"
|
||||
|
||||
type PointerMode = "none" | "text" | "numbers"
|
||||
type Side = SelectedLineRange["side"]
|
||||
type LineSpan = Pick<SelectedLineRange, "start" | "end">
|
||||
|
||||
export function formatSelectedLineLabel(range: LineSpan) {
|
||||
const start = Math.min(range.start, range.end)
|
||||
const end = Math.max(range.start, range.end)
|
||||
if (start === end) return `line ${start}`
|
||||
return `lines ${start}-${end}`
|
||||
}
|
||||
|
||||
export function previewSelectedLines(source: string, range: LineSpan) {
|
||||
const start = Math.max(1, Math.min(range.start, range.end))
|
||||
const end = Math.max(range.start, range.end)
|
||||
const lines = source.split("\n").slice(start - 1, end)
|
||||
if (lines.length === 0) return
|
||||
return lines.slice(0, 2).join("\n")
|
||||
}
|
||||
|
||||
export function cloneSelectedLineRange(range: SelectedLineRange): SelectedLineRange {
|
||||
const next: SelectedLineRange = {
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
}
|
||||
|
||||
if (range.side) next.side = range.side
|
||||
if (range.endSide) next.endSide = range.endSide
|
||||
return next
|
||||
}
|
||||
|
||||
export function lineInSelectedRange(range: SelectedLineRange | null | undefined, line: number, side?: Side) {
|
||||
if (!range) return false
|
||||
|
||||
const start = Math.min(range.start, range.end)
|
||||
const end = Math.max(range.start, range.end)
|
||||
if (line < start || line > end) return false
|
||||
if (!side) return true
|
||||
|
||||
const first = range.side
|
||||
const last = range.endSide ?? first
|
||||
if (!first && !last) return true
|
||||
if (!first || !last) return (first ?? last) === side
|
||||
if (first === last) return first === side
|
||||
if (line === start) return first === side
|
||||
if (line === end) return last === side
|
||||
return true
|
||||
}
|
||||
|
||||
export function isSingleLineSelection(range: SelectedLineRange | null) {
|
||||
if (!range) return false
|
||||
return range.start === range.end && (range.endSide == null || range.endSide === range.side)
|
||||
}
|
||||
|
||||
export function toRange(source: Range | StaticRange): Range {
|
||||
if (source instanceof Range) return source
|
||||
const range = new Range()
|
||||
range.setStart(source.startContainer, source.startOffset)
|
||||
range.setEnd(source.endContainer, source.endOffset)
|
||||
return range
|
||||
}
|
||||
|
||||
export function restoreShadowTextSelection(root: ShadowRoot | undefined, range: Range | undefined) {
|
||||
if (!root || !range) return
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const selection =
|
||||
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
|
||||
if (!selection) return
|
||||
|
||||
try {
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
} catch {}
|
||||
})
|
||||
}
|
||||
|
||||
export function createLineNumberSelectionBridge() {
|
||||
let mode: PointerMode = "none"
|
||||
let line: number | undefined
|
||||
let moved = false
|
||||
let pending = false
|
||||
|
||||
const clear = () => {
|
||||
mode = "none"
|
||||
line = undefined
|
||||
moved = false
|
||||
}
|
||||
|
||||
return {
|
||||
begin(numberColumn: boolean, next: number | undefined) {
|
||||
if (!numberColumn) {
|
||||
mode = "text"
|
||||
return
|
||||
}
|
||||
|
||||
mode = "numbers"
|
||||
line = next
|
||||
moved = false
|
||||
},
|
||||
track(buttons: number, next: number | undefined) {
|
||||
if (mode !== "numbers") return false
|
||||
|
||||
if ((buttons & 1) === 0) {
|
||||
clear()
|
||||
return true
|
||||
}
|
||||
|
||||
if (next !== undefined && line !== undefined && next !== line) moved = true
|
||||
return true
|
||||
},
|
||||
finish() {
|
||||
const current = mode
|
||||
pending = current === "numbers" && moved
|
||||
clear()
|
||||
return current
|
||||
},
|
||||
consume(range: SelectedLineRange | null) {
|
||||
const result = pending && !isSingleLineSelection(range)
|
||||
pending = false
|
||||
return result
|
||||
},
|
||||
reset() {
|
||||
pending = false
|
||||
clear()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user