Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
/**
|
||||
* Returns true only after `active` has stayed true continuously for `delayMs`.
|
||||
* Flips back to false the instant `active` goes false. Use it to gate loading
|
||||
* skeletons so a fast operation doesn't flash one — the UI just stays blank for
|
||||
* the (sub-perceptible) delay window, and the skeleton appears only when a load
|
||||
* is genuinely slow.
|
||||
*/
|
||||
export function useDelayedTrue(active: boolean, delayMs = 180): boolean {
|
||||
const [shown, setShown] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) {
|
||||
setShown(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const id = window.setTimeout(() => setShown(true), delayMs)
|
||||
|
||||
return () => window.clearTimeout(id)
|
||||
}, [active, delayMs])
|
||||
|
||||
return shown
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { type MouseEvent as ReactMouseEvent, type RefObject, useState } from 'react'
|
||||
|
||||
// Grab-to-pan for overflow containers — the shared primitive behind "scrub the
|
||||
// board/timeline by dragging its background" (kanban lanes, trace waterfalls,
|
||||
// wide tables). Sibling of lib/trackpad-gestures.ts: that file classifies
|
||||
// wheel gestures, this one owns pointer-drag panning, so surfaces stop
|
||||
// re-deriving the same interaction (the dashboard kanban and the agent-traces
|
||||
// waterfall each hand-rolled a copy).
|
||||
//
|
||||
// Behavior contract:
|
||||
// - drags translate scrollLeft/scrollTop (both axes, whichever overflow);
|
||||
// - interactive targets never start a pan (buttons, inputs, links,
|
||||
// [draggable] cards keep their own drag semantics);
|
||||
// - the native scrollbar gutters stay untouched as the fallback affordance;
|
||||
// - selection can't start mid-pan (preventDefault on move), and window
|
||||
// blur/mouseup always end it.
|
||||
|
||||
const BLOCKED_TARGETS = 'button,input,textarea,select,a,[role="button"],[draggable="true"]'
|
||||
const SCROLLBAR_GUTTER_PX = 16
|
||||
|
||||
export interface GrabScroll {
|
||||
/** True while a pan is in flight — drive `cursor-grabbing` styling. */
|
||||
grabbing: boolean
|
||||
/** Spread onto the scroll container. */
|
||||
onMouseDown: (event: ReactMouseEvent) => void
|
||||
}
|
||||
|
||||
export function useGrabScroll(ref: RefObject<HTMLElement | null>): GrabScroll {
|
||||
const [grabbing, setGrabbing] = useState(false)
|
||||
|
||||
const onMouseDown = (event: ReactMouseEvent) => {
|
||||
const el = ref.current
|
||||
|
||||
if (event.button !== 0 || !el) {
|
||||
return
|
||||
}
|
||||
|
||||
const canX = el.scrollWidth > el.clientWidth
|
||||
const canY = el.scrollHeight > el.clientHeight
|
||||
|
||||
if ((!canX && !canY) || (event.target as HTMLElement).closest(BLOCKED_TARGETS)) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
|
||||
if (
|
||||
(canX && event.clientY >= rect.bottom - SCROLLBAR_GUTTER_PX) ||
|
||||
(canY && event.clientX >= rect.right - SCROLLBAR_GUTTER_PX)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const start = { left: el.scrollLeft, top: el.scrollTop, x: event.clientX, y: event.clientY }
|
||||
setGrabbing(true)
|
||||
|
||||
const onMove = (move: MouseEvent) => {
|
||||
el.scrollLeft = start.left - (move.clientX - start.x)
|
||||
el.scrollTop = start.top - (move.clientY - start.y)
|
||||
move.preventDefault()
|
||||
}
|
||||
|
||||
const stop = () => {
|
||||
setGrabbing(false)
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', stop)
|
||||
window.removeEventListener('blur', stop)
|
||||
}
|
||||
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', stop, { once: true })
|
||||
window.addEventListener('blur', stop, { once: true })
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
return { grabbing, onMouseDown }
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { downloadFilename, imageFilename } from './use-image-download'
|
||||
|
||||
describe('imageFilename', () => {
|
||||
it('takes the last path segment of a URL', () => {
|
||||
expect(imageFilename('https://v3.fal.media/files/kangaroo/pic.png')).toBe('pic.png')
|
||||
})
|
||||
|
||||
it('falls back to "image" when there is no usable segment', () => {
|
||||
expect(imageFilename('https://example.com/')).toBe('image')
|
||||
expect(imageFilename(undefined)).toBe('image')
|
||||
})
|
||||
})
|
||||
|
||||
describe('downloadFilename', () => {
|
||||
it('keeps a name that already has a known image extension', () => {
|
||||
expect(downloadFilename('https://example.com/a/photo.jpg', 'image/jpeg')).toBe('photo.jpg')
|
||||
expect(downloadFilename('https://example.com/a/photo.webp', '')).toBe('photo.webp')
|
||||
})
|
||||
|
||||
it('appends a MIME-derived extension to extensionless content hashes', () => {
|
||||
expect(downloadFilename('https://v3.fal.media/files/x/MKZV6h-RrKLVCOKp9bGfE_YuJPemAQ', 'image/jpeg')).toBe(
|
||||
'MKZV6h-RrKLVCOKp9bGfE_YuJPemAQ.jpg'
|
||||
)
|
||||
expect(downloadFilename('https://cdn.example.com/abc123', 'image/webp')).toBe('abc123.webp')
|
||||
})
|
||||
|
||||
it('handles MIME parameters and unknown types', () => {
|
||||
expect(downloadFilename('https://cdn.example.com/abc123', 'image/png; charset=binary')).toBe('abc123.png')
|
||||
expect(downloadFilename('https://cdn.example.com/abc123', 'application/octet-stream')).toBe('abc123.png')
|
||||
expect(downloadFilename('https://cdn.example.com/abc123', undefined)).toBe('abc123.png')
|
||||
})
|
||||
|
||||
it('does not treat a dotted hash suffix as an extension', () => {
|
||||
// A name like "photo.v2" has an extname but not a known image one — the
|
||||
// MIME extension still gets appended so the OS can open the file.
|
||||
expect(downloadFilename('https://cdn.example.com/photo.v2', 'image/png')).toBe('photo.v2.png')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
const MIME_EXTENSIONS: Record<string, string> = {
|
||||
'image/bmp': '.bmp',
|
||||
'image/gif': '.gif',
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/svg+xml': '.svg',
|
||||
'image/webp': '.webp'
|
||||
}
|
||||
|
||||
const KNOWN_IMAGE_EXTENSION_RE = /\.(?:apng|avif|bmp|gif|ico|jpe?g|png|svg|tiff?|webp)$/i
|
||||
|
||||
export function imageFilename(src?: string): string {
|
||||
if (!src) {
|
||||
return 'image'
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(src, window.location.href).pathname.split('/').filter(Boolean).pop() || 'image'
|
||||
} catch {
|
||||
return src.split(/[\\/]/).filter(Boolean).pop() || 'image'
|
||||
}
|
||||
}
|
||||
|
||||
/** Filename for a browser-anchor download. Generated-image URLs (fal.media
|
||||
* etc.) often end in an extensionless content hash — without an extension the
|
||||
* OS save dialog shows "All Files" and the saved file won't open by
|
||||
* double-click, so append one derived from the blob's MIME type. */
|
||||
export function downloadFilename(src: string, mimeType?: string): string {
|
||||
const base = imageFilename(src)
|
||||
|
||||
if (KNOWN_IMAGE_EXTENSION_RE.test(base)) {
|
||||
return base
|
||||
}
|
||||
|
||||
const type = String(mimeType || '')
|
||||
.split(';')[0]
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
|
||||
return `${base}${MIME_EXTENSIONS[type] || '.png'}`
|
||||
}
|
||||
|
||||
function isMissingIpcHandler(error: unknown): boolean {
|
||||
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : ''
|
||||
|
||||
return message.includes("No handler registered for 'hermes:saveImageFromUrl'")
|
||||
}
|
||||
|
||||
async function startBrowserDownload(src: string) {
|
||||
const response = await fetch(src)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Could not fetch image: ${response.status}`)
|
||||
}
|
||||
|
||||
const blob = await response.blob()
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = blobUrl
|
||||
link.download = downloadFilename(src, blob.type)
|
||||
link.rel = 'noopener noreferrer'
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
link.remove()
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000)
|
||||
}
|
||||
|
||||
/** Save an image to disk via the desktop IPC bridge, falling back to a browser
|
||||
* download when the handler is unavailable (older shell / web preview). */
|
||||
export function useImageDownload(src?: string) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.desktop
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const download = useCallback(async () => {
|
||||
if (!src || saving) {
|
||||
return
|
||||
}
|
||||
|
||||
setSaving(true)
|
||||
|
||||
try {
|
||||
if (window.hermesDesktop?.saveImageFromUrl) {
|
||||
if (await window.hermesDesktop.saveImageFromUrl(src)) {
|
||||
notify({ kind: 'success', title: copy.imageSaved, message: imageFilename(src) })
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await startBrowserDownload(src)
|
||||
} catch (error) {
|
||||
if (isMissingIpcHandler(error)) {
|
||||
try {
|
||||
await startBrowserDownload(src)
|
||||
notify({ kind: 'info', title: copy.downloadStarted, message: copy.restartToUseSaveImage })
|
||||
} catch (fallbackError) {
|
||||
notifyError(fallbackError, copy.restartToSaveImages)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
notifyError(error, copy.imageDownloadFailed)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [copy, saving, src])
|
||||
|
||||
return { download, saving }
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export const matchesQuery = (query: string) =>
|
||||
typeof window !== 'undefined' && !!window.matchMedia && window.matchMedia(query).matches
|
||||
|
||||
/** Read at call time, not render time: animations check this as they start, and
|
||||
* the OS setting can flip mid-session. */
|
||||
export const prefersReducedMotion = () => matchesQuery('(prefers-reduced-motion: reduce)')
|
||||
|
||||
export function useMediaQuery(query: string): boolean {
|
||||
const [matches, setMatches] = useState(() => matchesQuery(query))
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) {
|
||||
return
|
||||
}
|
||||
|
||||
const mql = window.matchMedia(query)
|
||||
const onChange = () => setMatches(mql.matches)
|
||||
|
||||
setMatches(mql.matches)
|
||||
mql.addEventListener('change', onChange)
|
||||
|
||||
return () => mql.removeEventListener('change', onChange)
|
||||
}, [query])
|
||||
|
||||
return matches
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useMediaQuery } from './use-media-query'
|
||||
|
||||
export const useIsMobile = () => useMediaQuery(`(max-width: ${768 / 16 - 1 / 16}rem)`)
|
||||
@@ -0,0 +1,134 @@
|
||||
import { type RefObject, useLayoutEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Observe element resizes. The callback receives the ResizeObserver entries
|
||||
* (empty only in non-RO environments) so callers can read the observed size
|
||||
* off the entry instead of forcing a fresh layout read.
|
||||
*
|
||||
* The initial measurement rides the observer's spec-guaranteed first delivery
|
||||
* (same frame, after layout, before paint) instead of a synchronous call from
|
||||
* the layout effect. A sync call here runs while the commit's layout is still
|
||||
* dirty, so any size read in the callback forces a full reflow — and with many
|
||||
* instances mounting at once (every user bubble on a session switch), the
|
||||
* interleaved read→write→read pattern cascades into seconds of layout thrash.
|
||||
* Inside RO timing, layout is already clean and the same reads are ~free.
|
||||
*
|
||||
* ONE observer is shared by every caller. A private `new ResizeObserver` per
|
||||
* hook instance means the browser delivers one callback PER CONSUMER when a
|
||||
* common ancestor resizes, and each of those is a separate trip through the
|
||||
* observer machinery. A single shared observer batches the same work into one
|
||||
* delivery carrying many entries.
|
||||
*
|
||||
* Measured on a sash drag with five mounted session tiles (~100 user bubbles):
|
||||
* 2,600 callbacks across 40 pointermoves — 65 separate callbacks per frame,
|
||||
* each carrying exactly one entry — and 977ms of script time attributed to
|
||||
* this file. Batching collapses that to one callback per frame.
|
||||
*/
|
||||
|
||||
type Handler = (entries: readonly ResizeObserverEntry[]) => void
|
||||
|
||||
/** Live target → handler routing for the shared observer. */
|
||||
const handlers = new WeakMap<Element, Set<Handler>>()
|
||||
|
||||
let shared: null | ResizeObserver = null
|
||||
|
||||
function sharedObserver(): null | ResizeObserver {
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!shared) {
|
||||
shared = new ResizeObserver(entries => {
|
||||
// Group this delivery's entries by handler so a caller observing several
|
||||
// elements is still invoked once, with all of its entries — the same
|
||||
// contract a private observer gave it.
|
||||
const byHandler = new Map<Handler, ResizeObserverEntry[]>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const targets = handlers.get(entry.target)
|
||||
|
||||
if (!targets) {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const handler of targets) {
|
||||
const list = byHandler.get(handler)
|
||||
|
||||
if (list) {
|
||||
list.push(entry)
|
||||
} else {
|
||||
byHandler.set(handler, [entry])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [handler, group] of byHandler) {
|
||||
handler(group)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return shared
|
||||
}
|
||||
|
||||
export function useResizeObserver(
|
||||
onResize: (entries: readonly ResizeObserverEntry[]) => void,
|
||||
...refs: readonly RefObject<Element | null>[]
|
||||
) {
|
||||
const refsRef = useRef(refs)
|
||||
refsRef.current = refs
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const observer = sharedObserver()
|
||||
|
||||
if (!observer) {
|
||||
onResize([])
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const observed: Element[] = []
|
||||
|
||||
for (const ref of refsRef.current) {
|
||||
const element = ref.current
|
||||
|
||||
if (!element) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = handlers.get(element)
|
||||
|
||||
if (existing) {
|
||||
existing.add(onResize)
|
||||
} else {
|
||||
handlers.set(element, new Set([onResize]))
|
||||
// Only the first handler for an element needs to register it; the
|
||||
// observer fires once per element regardless of how many care.
|
||||
observer.observe(element)
|
||||
}
|
||||
|
||||
observed.push(element)
|
||||
}
|
||||
|
||||
if (observed.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const element of observed) {
|
||||
const set = handlers.get(element)
|
||||
|
||||
if (!set) {
|
||||
continue
|
||||
}
|
||||
|
||||
set.delete(onResize)
|
||||
|
||||
if (set.size === 0) {
|
||||
handlers.delete(element)
|
||||
observer.unobserve(element)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [onResize])
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
// Theme repaints (themes/context.tsx) toggle `.dark` + rewrite inline custom
|
||||
// props/data-hermes-* on <html>. Canvas/probe consumers that rasterize the
|
||||
// *computed* color-mix()/oklch tokens must re-resolve AFTER the paint — useTheme()
|
||||
// can't, since a child's effect runs before the provider's applyTheme. A
|
||||
// MutationObserver fires post-mutation, so the next getComputedStyle is fresh.
|
||||
// One observer, fanned out to every listener.
|
||||
const ATTRS = ['class', 'style', 'data-hermes-mode', 'data-hermes-theme']
|
||||
const listeners = new Set<() => void>()
|
||||
let observer: MutationObserver | null = null
|
||||
|
||||
/** Subscribe to theme repaints imperatively (ref/canvas, no re-render). */
|
||||
export function onThemeRepaint(fn: () => void): () => void {
|
||||
if (!observer && typeof document !== 'undefined') {
|
||||
observer = new MutationObserver(() => listeners.forEach(l => l()))
|
||||
observer.observe(document.documentElement, { attributeFilter: ATTRS, attributes: true })
|
||||
}
|
||||
|
||||
listeners.add(fn)
|
||||
|
||||
return () => void listeners.delete(fn)
|
||||
}
|
||||
|
||||
/** A counter that ticks on every theme repaint — depend on it to re-resolve colors. */
|
||||
export function useThemeEpoch(): number {
|
||||
const [epoch, setEpoch] = useState(0)
|
||||
|
||||
useEffect(() => onThemeRepaint(() => setEpoch(e => e + 1)), [])
|
||||
|
||||
return epoch
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
/** Run a UI-only clock while this document is actually being viewed.
|
||||
*
|
||||
* macOS can leave an occluded BrowserWindow `visible`, and active streaming
|
||||
* deliberately disables Chromium's background timer throttling. Pairing focus
|
||||
* with visibility avoids waking React for elapsed labels nobody can see while
|
||||
* a leading tick on return catches the UI up immediately.
|
||||
*/
|
||||
export function useViewedInterval(callback: () => void, intervalMs: number, enabled = true): void {
|
||||
const callbackRef = useRef(callback)
|
||||
|
||||
// eslint-disable-next-line no-restricted-syntax -- latest-callback ref avoids restarting the interval each render
|
||||
useEffect(() => {
|
||||
callbackRef.current = callback
|
||||
}, [callback])
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
return
|
||||
}
|
||||
|
||||
let intervalId: null | number = null
|
||||
|
||||
const stop = () => {
|
||||
if (intervalId !== null) {
|
||||
window.clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
const viewed = document.visibilityState === 'visible' && document.hasFocus()
|
||||
|
||||
if (!viewed) {
|
||||
stop()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (intervalId === null) {
|
||||
callbackRef.current()
|
||||
intervalId = window.setInterval(() => callbackRef.current(), intervalMs)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('focus', sync)
|
||||
window.addEventListener('blur', sync)
|
||||
document.addEventListener('visibilitychange', sync)
|
||||
sync()
|
||||
|
||||
return () => {
|
||||
stop()
|
||||
window.removeEventListener('focus', sync)
|
||||
window.removeEventListener('blur', sync)
|
||||
document.removeEventListener('visibilitychange', sync)
|
||||
}
|
||||
}, [enabled, intervalMs])
|
||||
}
|
||||
Reference in New Issue
Block a user