Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions src/lib/CmdPalette.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script
lang="ts"
generics="Action extends { label: string; action: (label: string) => void } & Record<string, unknown> = { label: string; action: (label: string) => void }"
generics="Action extends { label: string; action: (label: string) => void; group?: string } & Record<string, unknown> = { label: string; action: (label: string) => void; group?: string }"
>
import type { ComponentProps } from 'svelte'
import type { HTMLAttributes } from 'svelte/elements'
Expand All @@ -24,8 +24,7 @@
triggers?: string[]
close_keys?: string[]
fade_duration?: number // in ms
dialog_style?: string // for dialog
// for span in option snippet, has no effect when specifying a custom option snippet
dialog_style?: string // inline style for the dialog element
open?: boolean
dialog?: HTMLDialogElement | null
input?: HTMLInputElement | null
Expand Down
42 changes: 27 additions & 15 deletions src/lib/FileDetails.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
<script lang="ts">
// import hljs from 'highlight.js'
// import 'highlight.js/styles/vs2015.css'
import type { Snippet } from 'svelte'
import type { HTMLAttributes, HTMLDetailsAttributes } from 'svelte/elements'

Expand All @@ -16,42 +14,57 @@
toggle_all_btn_title = `Toggle all`,
default_lang = `typescript`,
as = `ol`,
style = null,
title_snippet,
button_props,
details_props,
...rest
}: {
files?: File[]
toggle_all_btn_title?: string
default_lang?: string
as?: string
style?: string | null
title_snippet?: Snippet<[{ idx: number } & File]>
button_props?: HTMLAttributes<HTMLButtonElement>
details_props?: HTMLDetailsAttributes
} = $props()
} & HTMLAttributes<HTMLOListElement> = $props()

function toggle_all() {
const any_open = files.some((file) => file.node?.open)
for (const file of files) {
if (!file.node) continue
file.node.open = !any_open
// Use reactive state for node refs to avoid binding_property_non_reactive warning
let node_refs = $state<(HTMLDetailsElement | null)[]>([])

// Trim stale refs when files shrink and sync node_refs back to files.node for external access
$effect(() => {
// Trim stale references when files array shrinks to prevent memory leaks
if (node_refs.length > files.length) {
node_refs.splice(files.length)
}
for (const [idx, node] of node_refs.entries()) {
if (files[idx]) files[idx].node = node
}
})

// Check if any nodes are open (for button text)
const any_open = $derived(node_refs.some((node) => node?.open))

function toggle_all() { // Read current DOM state fresh (can't use $derived any_open here - it may be stale)
const should_close = node_refs.some((node) => node?.open)
for (const node of node_refs) {
if (!node) continue
node.open = !should_close
}
}
</script>

{#if files?.length > 1}
<button onclick={toggle_all} title={toggle_all_btn_title} {...button_props}>
{files.some((file) => file.node?.open) ? `Close` : `Open`} all
{any_open ? `Close` : `Open`} all
</button>
{/if}

<svelte:element this={as} {style}>
<svelte:element this={as} {...rest}>
{#each files as file, idx (file.title)}
{@const { title, content, language = default_lang } = file ?? {}}
<li>
<!-- https://github.com/sveltejs/svelte/issues/12721#issuecomment-2269544690 -->
<details bind:this={file.node} {...details_props}>
<details bind:this={node_refs[idx]} {...details_props}>
{#if title || title_snippet}
<summary>
{#if title_snippet}
Expand All @@ -63,7 +76,6 @@
{/if}

<pre class="language-{language}"><code>{content}</code></pre>
<!-- <pre><code>{@html hljs.highlight(content, { language }).value}</code></pre> -->
</details>
</li>
{/each}
Expand Down
95 changes: 94 additions & 1 deletion src/lib/MultiSelect.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import { highlight_matches } from './attachments'
import CircleSpinner from './CircleSpinner.svelte'
import Icon from './Icon.svelte'
import type { GroupedOptions, MultiSelectProps } from './types'
import type { GroupedOptions, KeyboardShortcuts, MultiSelectProps } from './types'
import { fuzzy_match, get_label, get_style, has_group, is_object } from './utils'
import Wiggle from './Wiggle.svelte'

Expand Down Expand Up @@ -145,9 +145,59 @@
onexpandAll,
collapseAllGroups = $bindable(),
expandAllGroups = $bindable(),
// Keyboard shortcuts for common actions
shortcuts = {},
...rest
}: MultiSelectProps<Option> = $props()

// Parse shortcut string into modifier+key parts
function parse_shortcut(shortcut: string): {
key: string
ctrl: boolean
shift: boolean
alt: boolean
meta: boolean
} {
const parts = shortcut.toLowerCase().split(`+`)
const key = parts.pop() ?? ``
return {
key,
ctrl: parts.includes(`ctrl`),
shift: parts.includes(`shift`),
alt: parts.includes(`alt`),
meta: parts.includes(`meta`) || parts.includes(`cmd`),
}
}

function matches_shortcut(
event: KeyboardEvent,
shortcut: string | null | undefined,
): boolean {
if (!shortcut) return false
const parsed = parse_shortcut(shortcut)
const key_matches = event.key.toLowerCase() === parsed.key
const ctrl_matches = event.ctrlKey === parsed.ctrl
const shift_matches = event.shiftKey === parsed.shift
const alt_matches = event.altKey === parsed.alt
const meta_matches = event.metaKey === parsed.meta
return (
key_matches && ctrl_matches && shift_matches && alt_matches && meta_matches
)
}

// Default shortcuts
const default_shortcuts: KeyboardShortcuts = {
select_all: `ctrl+a`,
clear_all: `ctrl+shift+a`,
open: null,
close: null,
}

const effective_shortcuts = $derived({
...default_shortcuts,
...shortcuts,
})

// Extract loadOptions config into single derived object (supports both simple function and config object)
const load_options_config = $derived.by(() => {
if (!loadOptions) return null
Expand Down Expand Up @@ -659,6 +709,49 @@

// handle all keyboard events this component receives
async function handle_keydown(event: KeyboardEvent) {
if (disabled) return // Block all keyboard handling when disabled

// Check keyboard shortcuts first (before other key handling)
const shortcut_actions: Array<{
key: keyof typeof effective_shortcuts
condition: () => boolean
action: () => void
}> = [
{
key: `select_all`,
condition: () =>
!!selectAllOption && navigable_options.length > 0 && maxSelect !== 1,
action: () => select_all(event),
},
{
key: `clear_all`,
condition: () => selected.length > 0,
action: () => remove_all(event),
},
{
key: `open`,
condition: () => !open,
action: () => open_dropdown(event),
},
{
key: `close`,
condition: () => open,
action: () => {
close_dropdown(event)
searchText = ``
},
},
]

for (const { key, condition, action } of shortcut_actions) {
if (matches_shortcut(event, effective_shortcuts[key]) && condition()) {
event.preventDefault()
event.stopPropagation()
action()
return
}
}

// on escape or tab out of input: close options dropdown and reset search text
if (event.key === `Escape` || event.key === `Tab`) {
event.stopPropagation()
Expand Down
8 changes: 6 additions & 2 deletions src/lib/PrevNext.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@
{:else}
<div>
{#if titles.prev}<span>{@html titles.prev}</span>{/if}
<a {...link_props} href={prev[0]}>{prev[0]}</a>
<a data-sveltekit-preload-data="hover" {...link_props} href={prev[0]}>{
prev[0]
}</a>
</div>
{/if}
{/if}
Expand All @@ -113,7 +115,9 @@
{:else}
<div>
{#if titles.next}<span>{@html titles.next}</span>{/if}
<a {...link_props} href={next[0]}>{next[0]}</a>
<a data-sveltekit-preload-data="hover" {...link_props} href={next[0]}>{
next[0]
}</a>
</div>
{/if}
{/if}
Expand Down
129 changes: 129 additions & 0 deletions src/lib/attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,22 @@ export interface DraggableOptions {
on_drag_end?: (event: MouseEvent) => void
}

export type Dimensions = { width: number; height: number }
export type ResizeCallback = (event: MouseEvent, dimensions: Dimensions) => void

export interface ResizableOptions {
edges?: (`top` | `right` | `bottom` | `left`)[]
min_width?: number
min_height?: number
max_width?: number
max_height?: number
handle_size?: number // px, default 8
disabled?: boolean
on_resize_start?: ResizeCallback
on_resize?: ResizeCallback
on_resize_end?: ResizeCallback
}

// Svelte 5 attachment factory to make an element draggable
// @param options - Configuration options for dragging behavior
// @returns Attachment function that sets up dragging on an element
Expand Down Expand Up @@ -119,6 +135,119 @@ export const draggable =
}
}

// Svelte 5 attachment factory to make an element resizable by dragging its edges
export const resizable =
(options: ResizableOptions = {}): Attachment => (element: Element) => {
if (options.disabled) return

const node = element as HTMLElement
const {
edges = [`right`, `bottom`],
min_width = 50,
min_height = 50,
max_width = Infinity,
max_height = Infinity,
handle_size = 8,
on_resize_start,
on_resize,
on_resize_end,
} = options

let active_edge: string | null = null
let start = { x: 0, y: 0 }
let initial = { width: 0, height: 0, left: 0, top: 0 }

const clamp = (val: number, min: number, max: number) =>
Math.max(min, Math.min(max, val))

if (getComputedStyle(node).position === `static`) node.style.position = `relative`

const get_edge = ({ clientX: cx, clientY: cy }: MouseEvent): string | null => {
const { left, right, top, bottom } = node.getBoundingClientRect()
if (edges.includes(`right`) && cx >= right - handle_size && cx <= right) {
return `right`
}
if (edges.includes(`bottom`) && cy >= bottom - handle_size && cy <= bottom) {
return `bottom`
}
if (edges.includes(`left`) && cx >= left && cx <= left + handle_size) return `left`
if (edges.includes(`top`) && cy >= top && cy <= top + handle_size) return `top`
return null
}

function on_mousedown(event: MouseEvent) {
active_edge = get_edge(event)
if (!active_edge) return

start = { x: event.clientX, y: event.clientY }
initial = {
width: node.offsetWidth,
height: node.offsetHeight,
left: node.offsetLeft,
top: node.offsetTop,
}
document.body.style.userSelect = `none`
on_resize_start?.(event, { width: initial.width, height: initial.height })
globalThis.addEventListener(`mousemove`, on_mousemove)
globalThis.addEventListener(`mouseup`, on_mouseup)
}

function on_mousemove(event: MouseEvent) {
if (!active_edge) return

const dx = event.clientX - start.x, dy = event.clientY - start.y
let { width, height } = initial

if (active_edge === `right`) width = clamp(initial.width + dx, min_width, max_width)
else if (active_edge === `left`) {
const clamped = clamp(initial.width - dx, min_width, max_width)
node.style.left = `${initial.left - (clamped - initial.width)}px`
width = clamped
}

if (active_edge === `bottom`) {
height = clamp(initial.height + dy, min_height, max_height)
} else if (active_edge === `top`) {
const clamped = clamp(initial.height - dy, min_height, max_height)
node.style.top = `${initial.top - (clamped - initial.height)}px`
height = clamped
}

node.style.width = `${width}px`
node.style.height = `${height}px`
on_resize?.(event, { width, height })
}

function on_mouseup(event: MouseEvent) {
if (!active_edge) return
document.body.style.userSelect = ``
on_resize_end?.(event, { width: node.offsetWidth, height: node.offsetHeight })
globalThis.removeEventListener(`mousemove`, on_mousemove)
globalThis.removeEventListener(`mouseup`, on_mouseup)
active_edge = null
}

function on_hover(event: MouseEvent) {
const edge = get_edge(event)
node.style.cursor = edge === `right` || edge === `left`
? `ew-resize`
: edge === `top` || edge === `bottom`
? `ns-resize`
: ``
}

node.addEventListener(`mousedown`, on_mousedown)
node.addEventListener(`mousemove`, on_hover)

return () => {
node.removeEventListener(`mousedown`, on_mousedown)
node.removeEventListener(`mousemove`, on_hover)
globalThis.removeEventListener(`mousemove`, on_mousemove)
globalThis.removeEventListener(`mouseup`, on_mouseup)
node.style.cursor = ``
}
}

export function get_html_sort_value(element: HTMLElement): string {
if (element.dataset.sortValue !== undefined) {
return element.dataset.sortValue
Expand Down
Loading