code.tsx 30 KB

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