code.tsx 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029
  1. import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs"
  2. import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
  3. import { Portal } from "solid-js/web"
  4. import { createDefaultOptions, styleVariables } from "../pierre"
  5. import { getWorkerPool } from "../pierre/worker"
  6. import { Icon } from "./icon"
  7. type SelectionSide = "additions" | "deletions"
  8. export type CodeProps<T = {}> = FileOptions<T> & {
  9. file: FileContents
  10. annotations?: LineAnnotation<T>[]
  11. selectedLines?: SelectedLineRange | null
  12. commentedLines?: SelectedLineRange[]
  13. onRendered?: () => void
  14. onLineSelectionEnd?: (selection: SelectedLineRange | null) => void
  15. class?: string
  16. classList?: ComponentProps<"div">["classList"]
  17. }
  18. function findElement(node: Node | null): HTMLElement | undefined {
  19. if (!node) return
  20. if (node instanceof HTMLElement) return node
  21. return node.parentElement ?? undefined
  22. }
  23. function findLineNumber(node: Node | null): number | undefined {
  24. const element = findElement(node)
  25. if (!element) return
  26. const line = element.closest("[data-line]")
  27. if (!(line instanceof HTMLElement)) return
  28. const value = parseInt(line.dataset.line ?? "", 10)
  29. if (Number.isNaN(value)) return
  30. return value
  31. }
  32. function findSide(node: Node | null): SelectionSide | undefined {
  33. const element = findElement(node)
  34. if (!element) return
  35. const code = element.closest("[data-code]")
  36. if (!(code instanceof HTMLElement)) return
  37. if (code.hasAttribute("data-deletions")) return "deletions"
  38. return "additions"
  39. }
  40. type FindHost = {
  41. element: () => HTMLElement | undefined
  42. open: () => void
  43. close: () => void
  44. next: (dir: 1 | -1) => void
  45. isOpen: () => boolean
  46. }
  47. const findHosts = new Set<FindHost>()
  48. let findTarget: FindHost | undefined
  49. let findCurrent: FindHost | undefined
  50. let findInstalled = false
  51. function isEditable(node: unknown): boolean {
  52. if (!(node instanceof HTMLElement)) return false
  53. if (node.closest("[data-prevent-autofocus]")) return true
  54. if (node.isContentEditable) return true
  55. return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(node.tagName)
  56. }
  57. function hostForNode(node: unknown): FindHost | undefined {
  58. if (!(node instanceof Node)) return
  59. for (const host of findHosts) {
  60. const el = host.element()
  61. if (el && el.isConnected && el.contains(node)) return host
  62. }
  63. }
  64. function installFindShortcuts() {
  65. if (findInstalled) return
  66. if (typeof window === "undefined") return
  67. findInstalled = true
  68. window.addEventListener(
  69. "keydown",
  70. (event) => {
  71. if (event.defaultPrevented) return
  72. const mod = event.metaKey || event.ctrlKey
  73. if (!mod) return
  74. const key = event.key.toLowerCase()
  75. if (key === "g") {
  76. const host = findCurrent
  77. if (!host || !host.isOpen()) return
  78. event.preventDefault()
  79. event.stopPropagation()
  80. host.next(event.shiftKey ? -1 : 1)
  81. return
  82. }
  83. if (key !== "f") return
  84. const current = findCurrent
  85. if (current && current.isOpen()) {
  86. event.preventDefault()
  87. event.stopPropagation()
  88. current.open()
  89. return
  90. }
  91. const host =
  92. hostForNode(document.activeElement) ?? hostForNode(event.target) ?? findTarget ?? Array.from(findHosts)[0]
  93. if (!host) return
  94. event.preventDefault()
  95. event.stopPropagation()
  96. host.open()
  97. },
  98. { capture: true },
  99. )
  100. }
  101. export function Code<T>(props: CodeProps<T>) {
  102. let wrapper!: HTMLDivElement
  103. let container!: HTMLDivElement
  104. let findInput: HTMLInputElement | undefined
  105. let findOverlay!: HTMLDivElement
  106. let findOverlayFrame: number | undefined
  107. let findOverlayScroll: HTMLElement[] = []
  108. let observer: MutationObserver | undefined
  109. let renderToken = 0
  110. let selectionFrame: number | undefined
  111. let dragFrame: number | undefined
  112. let dragStart: number | undefined
  113. let dragEnd: number | undefined
  114. let dragMoved = false
  115. let lastSelection: SelectedLineRange | null = null
  116. let pendingSelectionEnd = false
  117. const [local, others] = splitProps(props, [
  118. "file",
  119. "class",
  120. "classList",
  121. "annotations",
  122. "selectedLines",
  123. "commentedLines",
  124. "onRendered",
  125. ])
  126. const [rendered, setRendered] = createSignal(0)
  127. const [findOpen, setFindOpen] = createSignal(false)
  128. const [findQuery, setFindQuery] = createSignal("")
  129. const [findIndex, setFindIndex] = createSignal(0)
  130. const [findCount, setFindCount] = createSignal(0)
  131. let findMode: "highlights" | "overlay" = "overlay"
  132. let findHits: Range[] = []
  133. const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 })
  134. const file = createMemo(
  135. () =>
  136. new File<T>(
  137. {
  138. ...createDefaultOptions<T>("unified"),
  139. ...others,
  140. },
  141. getWorkerPool("unified"),
  142. ),
  143. )
  144. const getRoot = () => {
  145. const host = container.querySelector("diffs-container")
  146. if (!(host instanceof HTMLElement)) return
  147. const root = host.shadowRoot
  148. if (!root) return
  149. return root
  150. }
  151. const applyScheme = () => {
  152. const host = container.querySelector("diffs-container")
  153. if (!(host instanceof HTMLElement)) return
  154. const scheme = document.documentElement.dataset.colorScheme
  155. if (scheme === "dark" || scheme === "light") {
  156. host.dataset.colorScheme = scheme
  157. return
  158. }
  159. host.removeAttribute("data-color-scheme")
  160. }
  161. const supportsHighlights = () => {
  162. const g = globalThis as unknown as { CSS?: { highlights?: unknown }; Highlight?: unknown }
  163. return typeof g.Highlight === "function" && g.CSS?.highlights != null
  164. }
  165. const clearHighlightFind = () => {
  166. const api = (globalThis as { CSS?: { highlights?: { delete: (name: string) => void } } }).CSS?.highlights
  167. if (!api) return
  168. api.delete("opencode-find")
  169. api.delete("opencode-find-current")
  170. }
  171. const clearOverlayScroll = () => {
  172. for (const el of findOverlayScroll) el.removeEventListener("scroll", scheduleOverlay)
  173. findOverlayScroll = []
  174. }
  175. const clearOverlay = () => {
  176. if (findOverlayFrame !== undefined) {
  177. cancelAnimationFrame(findOverlayFrame)
  178. findOverlayFrame = undefined
  179. }
  180. findOverlay.innerHTML = ""
  181. }
  182. const renderOverlay = () => {
  183. if (findMode !== "overlay") {
  184. clearOverlay()
  185. return
  186. }
  187. clearOverlay()
  188. if (findHits.length === 0) return
  189. const base = wrapper.getBoundingClientRect()
  190. const current = findIndex()
  191. const frag = document.createDocumentFragment()
  192. for (let i = 0; i < findHits.length; i++) {
  193. const range = findHits[i]
  194. const active = i === current
  195. for (const rect of Array.from(range.getClientRects())) {
  196. if (!rect.width || !rect.height) continue
  197. const el = document.createElement("div")
  198. el.style.position = "absolute"
  199. el.style.left = `${Math.round(rect.left - base.left)}px`
  200. el.style.top = `${Math.round(rect.top - base.top)}px`
  201. el.style.width = `${Math.round(rect.width)}px`
  202. el.style.height = `${Math.round(rect.height)}px`
  203. el.style.borderRadius = "2px"
  204. el.style.backgroundColor = active ? "var(--surface-warning-strong)" : "var(--surface-warning-base)"
  205. el.style.opacity = active ? "0.55" : "0.35"
  206. if (active) el.style.boxShadow = "inset 0 0 0 1px var(--border-warning-base)"
  207. frag.appendChild(el)
  208. }
  209. }
  210. findOverlay.appendChild(frag)
  211. }
  212. function scheduleOverlay() {
  213. if (findMode !== "overlay") return
  214. if (!findOpen()) return
  215. if (findOverlayFrame !== undefined) return
  216. findOverlayFrame = requestAnimationFrame(() => {
  217. findOverlayFrame = undefined
  218. renderOverlay()
  219. })
  220. }
  221. const syncOverlayScroll = () => {
  222. if (findMode !== "overlay") return
  223. const root = getRoot()
  224. const next = root
  225. ? Array.from(root.querySelectorAll("[data-code]")).filter(
  226. (node): node is HTMLElement => node instanceof HTMLElement,
  227. )
  228. : []
  229. if (next.length === findOverlayScroll.length && next.every((el, i) => el === findOverlayScroll[i])) return
  230. clearOverlayScroll()
  231. findOverlayScroll = next
  232. for (const el of findOverlayScroll) el.addEventListener("scroll", scheduleOverlay, { passive: true })
  233. }
  234. const clearFind = () => {
  235. clearHighlightFind()
  236. clearOverlay()
  237. clearOverlayScroll()
  238. findHits = []
  239. setFindCount(0)
  240. setFindIndex(0)
  241. }
  242. const getScrollParent = (el: HTMLElement): HTMLElement | undefined => {
  243. let parent = el.parentElement
  244. while (parent) {
  245. const style = getComputedStyle(parent)
  246. if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
  247. parent = parent.parentElement
  248. }
  249. }
  250. const positionFindBar = () => {
  251. if (typeof window === "undefined") return
  252. const root = getScrollParent(wrapper) ?? wrapper
  253. const rect = root.getBoundingClientRect()
  254. const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
  255. const header = Number.isNaN(title) ? 0 : title
  256. setFindPos({
  257. top: Math.round(rect.top) + header - 4,
  258. right: Math.round(window.innerWidth - rect.right) + 8,
  259. })
  260. }
  261. const scanFind = (root: ShadowRoot, query: string) => {
  262. const needle = query.toLowerCase()
  263. const out: Range[] = []
  264. const cols = Array.from(root.querySelectorAll("[data-content] [data-line], [data-column-content]")).filter(
  265. (node): node is HTMLElement => node instanceof HTMLElement,
  266. )
  267. for (const col of cols) {
  268. const text = col.textContent
  269. if (!text) continue
  270. const hay = text.toLowerCase()
  271. let idx = hay.indexOf(needle)
  272. if (idx === -1) continue
  273. const nodes: Text[] = []
  274. const ends: number[] = []
  275. const walker = document.createTreeWalker(col, NodeFilter.SHOW_TEXT)
  276. let node = walker.nextNode()
  277. let pos = 0
  278. while (node) {
  279. if (node instanceof Text) {
  280. pos += node.data.length
  281. nodes.push(node)
  282. ends.push(pos)
  283. }
  284. node = walker.nextNode()
  285. }
  286. if (nodes.length === 0) continue
  287. const locate = (at: number) => {
  288. let lo = 0
  289. let hi = ends.length - 1
  290. while (lo < hi) {
  291. const mid = (lo + hi) >> 1
  292. if (ends[mid] >= at) hi = mid
  293. else lo = mid + 1
  294. }
  295. const prev = lo === 0 ? 0 : ends[lo - 1]
  296. return { node: nodes[lo], offset: at - prev }
  297. }
  298. while (idx !== -1) {
  299. const start = locate(idx)
  300. const end = locate(idx + query.length)
  301. const range = document.createRange()
  302. range.setStart(start.node, start.offset)
  303. range.setEnd(end.node, end.offset)
  304. out.push(range)
  305. idx = hay.indexOf(needle, idx + query.length)
  306. }
  307. }
  308. return out
  309. }
  310. const scrollToRange = (range: Range) => {
  311. const start = range.startContainer
  312. const el = start instanceof Element ? start : start.parentElement
  313. el?.scrollIntoView({ block: "center", inline: "center" })
  314. }
  315. const setHighlights = (ranges: Range[], index: number) => {
  316. const api = (globalThis as unknown as { CSS?: { highlights?: any }; Highlight?: any }).CSS?.highlights
  317. const Highlight = (globalThis as unknown as { Highlight?: any }).Highlight
  318. if (!api || typeof Highlight !== "function") return false
  319. api.delete("opencode-find")
  320. api.delete("opencode-find-current")
  321. const active = ranges[index]
  322. if (active) api.set("opencode-find-current", new Highlight(active))
  323. const rest = ranges.filter((_, i) => i !== index)
  324. if (rest.length > 0) api.set("opencode-find", new Highlight(...rest))
  325. return true
  326. }
  327. const applyFind = (opts?: { reset?: boolean; scroll?: boolean }) => {
  328. if (!findOpen()) return
  329. const query = findQuery().trim()
  330. if (!query) {
  331. clearFind()
  332. return
  333. }
  334. const root = getRoot()
  335. if (!root) return
  336. findMode = supportsHighlights() ? "highlights" : "overlay"
  337. const ranges = scanFind(root, query)
  338. const total = ranges.length
  339. const desired = opts?.reset ? 0 : findIndex()
  340. const index = total ? Math.min(desired, total - 1) : 0
  341. findHits = ranges
  342. setFindCount(total)
  343. setFindIndex(index)
  344. const active = ranges[index]
  345. if (findMode === "highlights") {
  346. clearOverlay()
  347. clearOverlayScroll()
  348. if (!setHighlights(ranges, index)) {
  349. findMode = "overlay"
  350. clearHighlightFind()
  351. syncOverlayScroll()
  352. scheduleOverlay()
  353. }
  354. if (opts?.scroll && active) {
  355. scrollToRange(active)
  356. }
  357. return
  358. }
  359. clearHighlightFind()
  360. syncOverlayScroll()
  361. if (opts?.scroll && active) {
  362. scrollToRange(active)
  363. }
  364. scheduleOverlay()
  365. }
  366. const closeFind = () => {
  367. setFindOpen(false)
  368. clearFind()
  369. if (findCurrent === host) findCurrent = undefined
  370. }
  371. const stepFind = (dir: 1 | -1) => {
  372. if (!findOpen()) return
  373. const total = findCount()
  374. if (total <= 0) return
  375. const index = (findIndex() + dir + total) % total
  376. setFindIndex(index)
  377. const active = findHits[index]
  378. if (!active) return
  379. if (findMode === "highlights") {
  380. if (!setHighlights(findHits, index)) {
  381. findMode = "overlay"
  382. applyFind({ reset: true, scroll: true })
  383. return
  384. }
  385. scrollToRange(active)
  386. return
  387. }
  388. clearHighlightFind()
  389. syncOverlayScroll()
  390. scrollToRange(active)
  391. scheduleOverlay()
  392. }
  393. const host: FindHost = {
  394. element: () => wrapper,
  395. isOpen: () => findOpen(),
  396. next: stepFind,
  397. open: () => {
  398. if (findCurrent && findCurrent !== host) findCurrent.close()
  399. findCurrent = host
  400. findTarget = host
  401. if (!findOpen()) setFindOpen(true)
  402. requestAnimationFrame(() => {
  403. applyFind({ scroll: true })
  404. findInput?.focus()
  405. findInput?.select()
  406. })
  407. },
  408. close: closeFind,
  409. }
  410. onMount(() => {
  411. findMode = supportsHighlights() ? "highlights" : "overlay"
  412. installFindShortcuts()
  413. findHosts.add(host)
  414. if (!findTarget) findTarget = host
  415. onCleanup(() => {
  416. findHosts.delete(host)
  417. if (findCurrent === host) {
  418. findCurrent = undefined
  419. clearHighlightFind()
  420. }
  421. if (findTarget === host) findTarget = undefined
  422. })
  423. })
  424. createEffect(() => {
  425. if (!findOpen()) return
  426. const update = () => positionFindBar()
  427. requestAnimationFrame(update)
  428. window.addEventListener("resize", update, { passive: true })
  429. const root = getScrollParent(wrapper) ?? wrapper
  430. const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update())
  431. observer?.observe(root)
  432. onCleanup(() => {
  433. window.removeEventListener("resize", update)
  434. observer?.disconnect()
  435. })
  436. })
  437. const applyCommentedLines = (ranges: SelectedLineRange[]) => {
  438. const root = getRoot()
  439. if (!root) return
  440. const existing = Array.from(root.querySelectorAll("[data-comment-selected]"))
  441. for (const node of existing) {
  442. if (!(node instanceof HTMLElement)) continue
  443. node.removeAttribute("data-comment-selected")
  444. }
  445. const annotations = Array.from(root.querySelectorAll("[data-line-annotation]")).filter(
  446. (node): node is HTMLElement => node instanceof HTMLElement,
  447. )
  448. for (const range of ranges) {
  449. const start = Math.max(1, Math.min(range.start, range.end))
  450. const end = Math.max(range.start, range.end)
  451. for (let line = start; line <= end; line++) {
  452. const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-column-number="${line}"]`))
  453. for (const node of nodes) {
  454. if (!(node instanceof HTMLElement)) continue
  455. node.setAttribute("data-comment-selected", "")
  456. }
  457. }
  458. for (const annotation of annotations) {
  459. const line = parseInt(annotation.dataset.lineAnnotation?.split(",")[1] ?? "", 10)
  460. if (Number.isNaN(line)) continue
  461. if (line < start || line > end) continue
  462. annotation.setAttribute("data-comment-selected", "")
  463. }
  464. }
  465. }
  466. const text = () => {
  467. const value = local.file.contents as unknown
  468. if (typeof value === "string") return value
  469. if (Array.isArray(value)) return value.join("\n")
  470. if (value == null) return ""
  471. return String(value)
  472. }
  473. const lineCount = () => {
  474. const value = text()
  475. const total = value.split("\n").length - (value.endsWith("\n") ? 1 : 0)
  476. return Math.max(1, total)
  477. }
  478. const applySelection = (range: SelectedLineRange | null) => {
  479. const root = getRoot()
  480. if (!root) return false
  481. const lines = lineCount()
  482. if (root.querySelectorAll("[data-line]").length < lines) return false
  483. if (!range) {
  484. file().setSelectedLines(null)
  485. return true
  486. }
  487. const start = Math.min(range.start, range.end)
  488. const end = Math.max(range.start, range.end)
  489. if (start < 1 || end > lines) {
  490. file().setSelectedLines(null)
  491. return true
  492. }
  493. if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) {
  494. file().setSelectedLines(null)
  495. return true
  496. }
  497. const normalized = (() => {
  498. if (range.endSide != null) return { start: range.start, end: range.end }
  499. if (range.side !== "deletions") return range
  500. if (root.querySelector("[data-deletions]") != null) return range
  501. return { start: range.start, end: range.end }
  502. })()
  503. file().setSelectedLines(normalized)
  504. return true
  505. }
  506. const notifyRendered = () => {
  507. observer?.disconnect()
  508. observer = undefined
  509. renderToken++
  510. const token = renderToken
  511. const lines = lineCount()
  512. const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines
  513. const notify = () => {
  514. if (token !== renderToken) return
  515. observer?.disconnect()
  516. observer = undefined
  517. requestAnimationFrame(() => {
  518. if (token !== renderToken) return
  519. applySelection(lastSelection)
  520. applyFind({ reset: true })
  521. local.onRendered?.()
  522. })
  523. }
  524. const root = getRoot()
  525. if (root && isReady(root)) {
  526. notify()
  527. return
  528. }
  529. if (typeof MutationObserver === "undefined") return
  530. const observeRoot = (root: ShadowRoot) => {
  531. if (isReady(root)) {
  532. notify()
  533. return
  534. }
  535. observer?.disconnect()
  536. observer = new MutationObserver(() => {
  537. if (token !== renderToken) return
  538. if (!isReady(root)) return
  539. notify()
  540. })
  541. observer.observe(root, { childList: true, subtree: true })
  542. }
  543. if (root) {
  544. observeRoot(root)
  545. return
  546. }
  547. observer = new MutationObserver(() => {
  548. if (token !== renderToken) return
  549. const root = getRoot()
  550. if (!root) return
  551. observeRoot(root)
  552. })
  553. observer.observe(container, { childList: true, subtree: true })
  554. }
  555. const updateSelection = () => {
  556. const root = getRoot()
  557. if (!root) return
  558. const selection =
  559. (root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ?? window.getSelection()
  560. if (!selection || selection.isCollapsed) return
  561. const domRange =
  562. (
  563. selection as unknown as {
  564. getComposedRanges?: (options?: { shadowRoots?: ShadowRoot[] }) => Range[]
  565. }
  566. ).getComposedRanges?.({ shadowRoots: [root] })?.[0] ??
  567. (selection.rangeCount > 0 ? selection.getRangeAt(0) : undefined)
  568. const startNode = domRange?.startContainer ?? selection.anchorNode
  569. const endNode = domRange?.endContainer ?? selection.focusNode
  570. if (!startNode || !endNode) return
  571. if (!root.contains(startNode) || !root.contains(endNode)) return
  572. const start = findLineNumber(startNode)
  573. const end = findLineNumber(endNode)
  574. if (start === undefined || end === undefined) return
  575. const startSide = findSide(startNode)
  576. const endSide = findSide(endNode)
  577. const side = startSide ?? endSide
  578. const selected: SelectedLineRange = {
  579. start,
  580. end,
  581. }
  582. if (side) selected.side = side
  583. if (endSide && side && endSide !== side) selected.endSide = endSide
  584. setSelectedLines(selected)
  585. }
  586. const setSelectedLines = (range: SelectedLineRange | null) => {
  587. lastSelection = range
  588. applySelection(range)
  589. }
  590. const scheduleSelectionUpdate = () => {
  591. if (selectionFrame !== undefined) return
  592. selectionFrame = requestAnimationFrame(() => {
  593. selectionFrame = undefined
  594. updateSelection()
  595. if (!pendingSelectionEnd) return
  596. pendingSelectionEnd = false
  597. props.onLineSelectionEnd?.(lastSelection)
  598. })
  599. }
  600. const updateDragSelection = () => {
  601. if (dragStart === undefined || dragEnd === undefined) return
  602. const start = Math.min(dragStart, dragEnd)
  603. const end = Math.max(dragStart, dragEnd)
  604. setSelectedLines({ start, end })
  605. }
  606. const scheduleDragUpdate = () => {
  607. if (dragFrame !== undefined) return
  608. dragFrame = requestAnimationFrame(() => {
  609. dragFrame = undefined
  610. updateDragSelection()
  611. })
  612. }
  613. const lineFromMouseEvent = (event: MouseEvent) => {
  614. const path = event.composedPath()
  615. let numberColumn = false
  616. let line: number | undefined
  617. for (const item of path) {
  618. if (!(item instanceof HTMLElement)) continue
  619. numberColumn = numberColumn || item.dataset.columnNumber != null
  620. if (line === undefined && item.dataset.line) {
  621. const parsed = parseInt(item.dataset.line, 10)
  622. if (!Number.isNaN(parsed)) line = parsed
  623. }
  624. if (numberColumn && line !== undefined) break
  625. }
  626. return { line, numberColumn }
  627. }
  628. const handleMouseDown = (event: MouseEvent) => {
  629. if (props.enableLineSelection !== true) return
  630. if (event.button !== 0) return
  631. const { line, numberColumn } = lineFromMouseEvent(event)
  632. if (numberColumn) return
  633. if (line === undefined) return
  634. dragStart = line
  635. dragEnd = line
  636. dragMoved = false
  637. }
  638. const handleMouseMove = (event: MouseEvent) => {
  639. if (props.enableLineSelection !== true) return
  640. if (dragStart === undefined) return
  641. if ((event.buttons & 1) === 0) {
  642. dragStart = undefined
  643. dragEnd = undefined
  644. dragMoved = false
  645. return
  646. }
  647. const { line } = lineFromMouseEvent(event)
  648. if (line === undefined) return
  649. dragEnd = line
  650. dragMoved = true
  651. scheduleDragUpdate()
  652. }
  653. const handleMouseUp = () => {
  654. if (props.enableLineSelection !== true) return
  655. if (dragStart === undefined) return
  656. if (!dragMoved) {
  657. pendingSelectionEnd = false
  658. const line = dragStart
  659. setSelectedLines({ start: line, end: line })
  660. props.onLineSelectionEnd?.(lastSelection)
  661. dragStart = undefined
  662. dragEnd = undefined
  663. dragMoved = false
  664. return
  665. }
  666. pendingSelectionEnd = true
  667. scheduleDragUpdate()
  668. scheduleSelectionUpdate()
  669. dragStart = undefined
  670. dragEnd = undefined
  671. dragMoved = false
  672. }
  673. const handleSelectionChange = () => {
  674. if (props.enableLineSelection !== true) return
  675. if (dragStart === undefined) return
  676. const selection = window.getSelection()
  677. if (!selection || selection.isCollapsed) return
  678. scheduleSelectionUpdate()
  679. }
  680. createEffect(() => {
  681. const current = file()
  682. onCleanup(() => {
  683. current.cleanUp()
  684. })
  685. })
  686. createEffect(() => {
  687. observer?.disconnect()
  688. observer = undefined
  689. container.innerHTML = ""
  690. const value = text()
  691. file().render({
  692. file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value },
  693. lineAnnotations: local.annotations,
  694. containerWrapper: container,
  695. })
  696. applyScheme()
  697. setRendered((value) => value + 1)
  698. notifyRendered()
  699. })
  700. createEffect(() => {
  701. if (typeof document === "undefined") return
  702. if (typeof MutationObserver === "undefined") return
  703. const root = document.documentElement
  704. const monitor = new MutationObserver(() => applyScheme())
  705. monitor.observe(root, { attributes: true, attributeFilter: ["data-color-scheme"] })
  706. applyScheme()
  707. onCleanup(() => monitor.disconnect())
  708. })
  709. createEffect(() => {
  710. rendered()
  711. const ranges = local.commentedLines ?? []
  712. requestAnimationFrame(() => applyCommentedLines(ranges))
  713. })
  714. createEffect(() => {
  715. setSelectedLines(local.selectedLines ?? null)
  716. })
  717. createEffect(() => {
  718. if (props.enableLineSelection !== true) return
  719. container.addEventListener("mousedown", handleMouseDown)
  720. container.addEventListener("mousemove", handleMouseMove)
  721. window.addEventListener("mouseup", handleMouseUp)
  722. document.addEventListener("selectionchange", handleSelectionChange)
  723. onCleanup(() => {
  724. container.removeEventListener("mousedown", handleMouseDown)
  725. container.removeEventListener("mousemove", handleMouseMove)
  726. window.removeEventListener("mouseup", handleMouseUp)
  727. document.removeEventListener("selectionchange", handleSelectionChange)
  728. })
  729. })
  730. onCleanup(() => {
  731. observer?.disconnect()
  732. clearOverlayScroll()
  733. clearOverlay()
  734. if (findCurrent === host) {
  735. findCurrent = undefined
  736. clearHighlightFind()
  737. }
  738. if (selectionFrame !== undefined) {
  739. cancelAnimationFrame(selectionFrame)
  740. selectionFrame = undefined
  741. }
  742. if (dragFrame !== undefined) {
  743. cancelAnimationFrame(dragFrame)
  744. dragFrame = undefined
  745. }
  746. dragStart = undefined
  747. dragEnd = undefined
  748. dragMoved = false
  749. lastSelection = null
  750. pendingSelectionEnd = false
  751. })
  752. const FindBar = (barProps: { class: string; style?: ComponentProps<"div">["style"] }) => (
  753. <div class={barProps.class} style={barProps.style} onPointerDown={(e) => e.stopPropagation()}>
  754. <Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
  755. <input
  756. ref={findInput}
  757. placeholder="Find"
  758. value={findQuery()}
  759. class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
  760. onInput={(e) => {
  761. setFindQuery(e.currentTarget.value)
  762. setFindIndex(0)
  763. applyFind({ reset: true, scroll: true })
  764. }}
  765. onKeyDown={(e) => {
  766. if (e.key === "Escape") {
  767. e.preventDefault()
  768. closeFind()
  769. return
  770. }
  771. if (e.key !== "Enter") return
  772. e.preventDefault()
  773. stepFind(e.shiftKey ? -1 : 1)
  774. }}
  775. />
  776. <div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
  777. {findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
  778. </div>
  779. <div class="flex items-center">
  780. <button
  781. type="button"
  782. class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
  783. disabled={findCount() === 0}
  784. aria-label="Previous match"
  785. onClick={() => stepFind(-1)}
  786. >
  787. <Icon name="chevron-down" size="small" class="rotate-180" />
  788. </button>
  789. <button
  790. type="button"
  791. class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
  792. disabled={findCount() === 0}
  793. aria-label="Next match"
  794. onClick={() => stepFind(1)}
  795. >
  796. <Icon name="chevron-down" size="small" />
  797. </button>
  798. </div>
  799. <button
  800. type="button"
  801. class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
  802. aria-label="Close search"
  803. onClick={closeFind}
  804. >
  805. <Icon name="close-small" size="small" />
  806. </button>
  807. </div>
  808. )
  809. return (
  810. <div
  811. data-component="code"
  812. style={styleVariables}
  813. class="relative outline-none"
  814. classList={{
  815. ...(local.classList || {}),
  816. [local.class ?? ""]: !!local.class,
  817. }}
  818. ref={wrapper}
  819. tabIndex={0}
  820. onPointerDown={() => {
  821. findTarget = host
  822. wrapper.focus({ preventScroll: true })
  823. }}
  824. onFocus={() => {
  825. findTarget = host
  826. }}
  827. >
  828. <Show when={findOpen()}>
  829. <Portal>
  830. <FindBar
  831. class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
  832. style={{
  833. top: `${findPos().top}px`,
  834. right: `${findPos().right}px`,
  835. }}
  836. />
  837. </Portal>
  838. </Show>
  839. <div ref={container} />
  840. <div ref={findOverlay} class="pointer-events-none absolute inset-0 z-0" />
  841. </div>
  842. )
  843. }