-
-
Notifications
You must be signed in to change notification settings - Fork 103
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(www): add initial tracespace view page
- Loading branch information
Showing
27 changed files
with
1,305 additions
and
254 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import type {ComponentChildren} from 'preact' | ||
import type {StylableComponentProps} from './types' | ||
|
||
export interface FileInputProps extends StylableComponentProps { | ||
children: ComponentChildren | ||
onChange: JSX.DOMAttributes<HTMLInputElement>['onChange'] | ||
} | ||
|
||
export function FileInput(props: FileInputProps): JSX.Element { | ||
const {children, onChange, ...styleAttributes} = props | ||
|
||
return ( | ||
<label cursor="pointer" {...styleAttributes}> | ||
{children} | ||
<input onChange={onChange} sr="only" type="file" multiple /> | ||
</label> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,30 @@ | ||
import type {IconDefinition} from '@fortawesome/fontawesome-svg-core' | ||
|
||
import type {StylableComponentProps} from './types' | ||
|
||
export interface IconProps extends StylableComponentProps { | ||
data: IconDefinition | ||
} | ||
|
||
export function Icon(props: IconProps) { | ||
const {data, ...styleAttributes} = props | ||
const [width, height, _ligatures, _unicode, pathData] = data.icon | ||
const viewBox = `0 0 ${width} ${height}` | ||
const path = typeof pathData === 'string' ? pathData : pathData.join('') | ||
|
||
return ( | ||
<svg | ||
viewBox={viewBox} | ||
focusable="false" | ||
role="img" | ||
xmlns="http://www.w3.org/2000/svg" | ||
aria-hidden | ||
fill="currentColor" | ||
w="1em" | ||
h="1em" | ||
{...styleAttributes} | ||
> | ||
<path d={path} /> | ||
</svg> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import type {StylableComponentProps} from './types' | ||
|
||
export interface Props extends StylableComponentProps { | ||
value: number | ||
valueText: string | ||
min: number | ||
max: number | ||
onChange: (value: number) => unknown | ||
} | ||
|
||
export function LogSlider(props: Props): JSX.Element { | ||
const {value, valueText, min, max, onChange, ...styleProps} = props | ||
|
||
return ( | ||
<input | ||
cursor="grab" | ||
type="range" | ||
value={Math.log(value)} | ||
aria-valuetext={valueText} | ||
step="any" | ||
min={Math.log(min)} | ||
max={Math.log(max)} | ||
appearance="none slider-thumb:none" | ||
text="white dark:dark-800" | ||
bg="dark-800 dark:white" | ||
border="rounded-full" | ||
onChange={event => { | ||
if (event.target instanceof HTMLInputElement) { | ||
onChange(Math.E ** Number(event.target.value)) | ||
} | ||
}} | ||
{...styleProps} | ||
/> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export type {AttributifyAttributes as StylableComponentProps} from 'windicss/types/jsx' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,213 @@ | ||
import {useRef, useEffect} from 'preact/hooks' | ||
import type {Ref} from 'preact/hooks' | ||
|
||
export interface PanZoomOptions { | ||
initialScale?: number | ||
minScale?: number | ||
maxScale?: number | ||
onChange?: (state: PanZoomState) => unknown | ||
} | ||
|
||
export type PanZoomState = [scale: number, panX: number, panY: number] | ||
|
||
export type PanZoom = [ref: Ref<SVGGElement>, zoom?: PanZoomer] | ||
|
||
export function usePanZoom(options: PanZoomOptions = {}): PanZoom { | ||
const viewportRef = useRef<SVGGElement>(null) | ||
const panZoomerRef = useRef<PanZoomer>() | ||
|
||
useEffect(() => { | ||
if (viewportRef.current !== null) { | ||
const [cleanup, panZoomer] = initializePanZoom( | ||
viewportRef.current, | ||
options | ||
) | ||
panZoomerRef.current = panZoomer | ||
return cleanup | ||
} | ||
}, []) | ||
|
||
return [viewportRef, panZoomerRef.current] | ||
} | ||
|
||
interface PanZoomer { | ||
container: SVGSVGElement | ||
options: PanZoomOptions | ||
state: PanZoomState | ||
create(container: SVGSVGElement, options: PanZoomOptions): PanZoomer | ||
reset(): void | ||
pan(deltaX: number, deltaY: number): void | ||
zoom(deltaZ: number, centerX?: number, centerY?: number): void | ||
zoomTo(scale: number, centerX?: number, centerY?: number): void | ||
} | ||
|
||
const PanZoomerPrototype: PanZoomer = { | ||
container: undefined as unknown as SVGSVGElement, | ||
options: undefined as unknown as PanZoomOptions, | ||
state: undefined as unknown as PanZoomState, | ||
|
||
create(container: SVGSVGElement, options: PanZoomOptions): PanZoomer { | ||
const panZoomer = Object.assign(Object.create(PanZoomerPrototype), { | ||
container, | ||
options, | ||
state: [1, 0, 0], | ||
}) | ||
|
||
return panZoomer.reset() | ||
}, | ||
|
||
reset(): PanZoomer { | ||
const initialScale = this.options.initialScale ?? 1 | ||
const initialPanX = 0.5 * this.container.clientWidth * (1 - initialScale) | ||
const initialPanY = 0.5 * this.container.clientHeight * (1 - initialScale) | ||
const state = [initialScale, initialPanX, initialPanY] | ||
|
||
Object.assign(this, {state}) | ||
this.options.onChange?.(this.state) | ||
return this | ||
}, | ||
|
||
pan(deltaX: number, deltaY: number): void { | ||
this.state[1] += deltaX | ||
this.state[2] += deltaY | ||
|
||
this.options.onChange?.(this.state) | ||
}, | ||
|
||
zoom(deltaZ: number, centerX?: number, centerY?: number): void { | ||
const [scale] = this.state | ||
this.zoomTo(scale * (1 - deltaZ), centerX, centerY) | ||
}, | ||
|
||
zoomTo(scale: number, centerX?: number, centerY?: number): void { | ||
centerX = centerX ?? 0.5 * this.container.clientWidth | ||
centerY = centerY ?? 0.5 * this.container.clientHeight | ||
|
||
const [previousScale, panX, panY] = this.state | ||
const nextScale = clamp(scale, this.options.minScale, this.options.maxScale) | ||
|
||
const zoom = nextScale / previousScale | ||
const originX = centerX - panX | ||
const originY = centerY - panY | ||
|
||
this.state[0] = nextScale | ||
this.state[1] += originX * (1 - zoom) | ||
this.state[2] += originY * (1 - zoom) | ||
|
||
this.options.onChange?.(this.state) | ||
}, | ||
} | ||
|
||
function initializePanZoom( | ||
viewport: SVGGElement, | ||
options: PanZoomOptions | ||
): [cleanup: () => void, panZoomer: PanZoomer] { | ||
const updateViewport = throttle((state: PanZoomState) => { | ||
const [scale, panX, panY] = state | ||
viewport.style.transform = `translate(${panX}px,${panY}px) scale(${scale})` | ||
options.onChange?.([scale, panX, panY]) | ||
}) | ||
|
||
const container = viewport.parentElement as unknown as SVGSVGElement | ||
const panZoomer = PanZoomerPrototype.create(container, { | ||
...options, | ||
onChange: updateViewport, | ||
}) | ||
|
||
let mouseLocation: [x: number, y: number] | undefined | ||
|
||
const handleMouseDown = (event: MouseEvent) => { | ||
// eslint-disable-next-line no-bitwise | ||
if ((event.buttons & 1) === 1) { | ||
mouseLocation = [event.clientX, event.clientY] | ||
} | ||
} | ||
|
||
const handleMouseUp = () => { | ||
mouseLocation = undefined | ||
} | ||
|
||
const handleMouseMove = throttle((event: MouseEvent) => { | ||
if (mouseLocation !== undefined) { | ||
const movementX = event.clientX - mouseLocation[0] | ||
const movementY = event.clientY - mouseLocation[1] | ||
|
||
mouseLocation[0] = event.clientX | ||
mouseLocation[1] = event.clientY | ||
panZoomer.pan(movementX, movementY) | ||
} | ||
}) | ||
|
||
const handleWheel = (event: WheelEvent) => { | ||
panZoomer.zoom(event.deltaY / 1000, event.clientX, event.clientY) | ||
event.preventDefault() | ||
} | ||
|
||
const cleanup = () => { | ||
updateViewport.cancel() | ||
handleMouseMove.cancel() | ||
container.removeEventListener('mousedown', handleMouseDown) | ||
container.removeEventListener('mouseenter', handleMouseDown) | ||
container.removeEventListener('mouseup', handleMouseUp) | ||
container.removeEventListener('mouseleave', handleMouseUp) | ||
container.removeEventListener('mousemove', handleMouseMove) | ||
container.removeEventListener('wheel', handleWheel) | ||
} | ||
|
||
container.addEventListener('mousedown', handleMouseDown, {passive: true}) | ||
container.addEventListener('mouseenter', handleMouseDown, {passive: true}) | ||
container.addEventListener('mouseup', handleMouseUp, {passive: true}) | ||
container.addEventListener('mouseleave', handleMouseUp, {passive: true}) | ||
container.addEventListener('mousemove', handleMouseMove, {passive: true}) | ||
container.addEventListener('wheel', handleWheel, {passive: false}) | ||
|
||
return [cleanup, panZoomer] | ||
} | ||
|
||
export type Callback<A extends any[]> = (...args: A) => void | ||
|
||
export interface ThrottledCallback<A extends any[]> extends Callback<A> { | ||
cancel: () => void | ||
} | ||
|
||
function throttle<ArgsType extends any[]>( | ||
callback: Callback<ArgsType> | ||
): ThrottledCallback<ArgsType> { | ||
let requestId: number | undefined | ||
let latestArgs: ArgsType | undefined | ||
|
||
return Object.assign(throttledCallback, {cancel}) | ||
|
||
function throttledCallback(...args: ArgsType): void { | ||
latestArgs = args | ||
|
||
if (requestId === undefined) { | ||
requestId = requestAnimationFrame(doCallback) | ||
} | ||
} | ||
|
||
function doCallback() { | ||
if (latestArgs !== undefined) { | ||
callback(...latestArgs) | ||
requestId = undefined | ||
latestArgs = undefined | ||
} | ||
} | ||
|
||
function cancel() { | ||
if (requestId !== undefined) { | ||
cancelAnimationFrame(requestId) | ||
requestId = undefined | ||
} | ||
} | ||
} | ||
|
||
function clamp( | ||
value: number, | ||
min: number | undefined, | ||
max: number | undefined | ||
): number { | ||
if (min !== undefined && value < min) return min | ||
if (max !== undefined && value > max) return max | ||
return value | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
// @vitest-environment jsdom | ||
import {describe, it, afterEach, expect} from 'vitest' | ||
import {renderHook, act, cleanup} from '@testing-library/preact' | ||
|
||
import {useDarkMode as subject} from '..' | ||
|
||
describe('useDarkMode', () => { | ||
afterEach(async () => { | ||
document.querySelector('html')?.classList.remove('dark') | ||
cleanup() | ||
}) | ||
|
||
it('should initialize to false by default', () => { | ||
const {result} = renderHook(() => subject()) | ||
expect(result.current?.[0]).toEqual(false) | ||
}) | ||
|
||
it('should initialize to true if dark class present', () => { | ||
document.querySelector('html')?.classList.add('dark') | ||
|
||
const {result} = renderHook(() => subject()) | ||
|
||
expect(result.current?.[0]).toEqual(true) | ||
}) | ||
|
||
it('should toggle dark mode', async () => { | ||
const {result} = renderHook(() => subject()) | ||
|
||
expect(result.current?.[0]).toEqual(false) | ||
|
||
await act(() => { | ||
result.current?.[1]() | ||
}) | ||
|
||
expect(result.current?.[0]).toEqual(true) | ||
|
||
await act(() => { | ||
result.current?.[1]() | ||
}) | ||
|
||
expect(result.current?.[0]).toEqual(false) | ||
}) | ||
|
||
it('should add html class and persist to localstorage', async () => { | ||
const {result} = renderHook(() => subject()) | ||
|
||
await act(() => { | ||
result.current?.[1]() | ||
}) | ||
|
||
expect(document.querySelector('html')).toHaveClass('dark') | ||
expect(localStorage.getItem('tracespace:darkModeEnabled')).toEqual('true') | ||
|
||
await act(() => { | ||
result.current?.[1]() | ||
}) | ||
|
||
expect(document.querySelector('html')).not.toHaveClass('dark') | ||
expect(localStorage.getItem('tracespace:darkModeEnabled')).toEqual('false') | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,26 @@ | ||
import {useState, useEffect} from 'preact/hooks' | ||
import {faSun} from '@fortawesome/free-solid-svg-icons/faSun' | ||
import {faMoon} from '@fortawesome/free-solid-svg-icons/faMoon' | ||
|
||
import {Icon} from '../components/icon' | ||
import type {StylableComponentProps} from '../components/types' | ||
import {useDarkMode} from './use-dark-mode' | ||
|
||
export interface DarkModeToggleProps extends StylableComponentProps {} | ||
|
||
export function DarkModeToggle(props: DarkModeToggleProps): JSX.Element { | ||
const [isDark, toggleIsDark] = useDarkMode() | ||
const iconData = isDark ? faMoon : faSun | ||
const labelText = `Switch to ${isDark ? 'light' : 'dark'} mode` | ||
|
||
return ( | ||
<button | ||
title={labelText} | ||
aria-label={labelText} | ||
onClick={toggleIsDark} | ||
{...props} | ||
> | ||
<Icon data={iconData} /> | ||
</button> | ||
) | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export * from './dark-mode-toggle' | ||
export * from './use-dark-mode' |
Oops, something went wrong.