diff --git a/AGENTS.md b/AGENTS.md index 51fb34fa..c3c43f94 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -713,6 +713,11 @@ core: 9. **Use comments where applicable** - But not liberally; code should be self-documenting 10. **Core actions interfaces should have docstrings** - Document public API methods 11. **PR titles must follow main's commit-title pattern** - Use lowercase Conventional Commit style, typically `(): ` (e.g. `fix(web): ...`, `impl(core): ...`, `docs: ...`) +12. **Prefer `impl(): ...` for feature/additive PR titles** - Use `fix(...)` for bug fixes and keep key feature names explicit in the title (e.g. `impl(web): ToggleFullScreen and ToggleFitMedia keybinds`) +13. **PR body style should be concise and structured**: + - Use `## Summary` with short bullets in the form `implements ...` / `improves ...` + - Use a separate `## Vision Testing` section describing manual + automated verification + - Prefer practical behavior-focused bullets over long prose ### Security Considerations diff --git a/packages/web/src/lib/keybinds.ts b/packages/web/src/lib/keybinds.ts index 2ac2670c..b1f28301 100644 --- a/packages/web/src/lib/keybinds.ts +++ b/packages/web/src/lib/keybinds.ts @@ -3,13 +3,19 @@ import type { Config } from '$lib/server/config.ts' type KeybindAction = + | 'OpenMedia' | 'Escape' | 'Search' + | 'AddTag' | 'PrevMedia' | 'NextMedia' | 'PrevTagSuggestion' | 'NextTagSuggestion' + | 'CopyMedia' + | 'ToggleFitMedia' + | 'ToggleFullScreen' | 'PlayPauseMedia' + | 'ToggleVideoMute' | 'ToggleMediaControls' | 'ToggleSidebar' | 'Star0' @@ -19,7 +25,11 @@ type KeybindAction = | 'Star4' | 'Star5' -type KeybindActionListener = (e: KeyboardEvent) => void +interface KeybindActionEventDetail { + data: { keyboard_event: KeyboardEvent } +} +type KeybindActionEvent = CustomEvent +type KeybindActionListener = (e: KeybindActionEvent) => void export class Keybinds { public emitter: EventTarget @@ -27,6 +37,42 @@ export class Keybinds { #keybind_mapper: Map | undefined #config: Config | undefined + // this function is for some user ergonomics. + // It makes sure the user defined key order (Ctrl-Shift-Alt-S) matches the way we grab keybinds internally + // (same order, replace common mispellings like Control -> Ctrl) + #get_keybind_code(code: string) { + const keycodes = code + .split('-') + .map(part => { + const normalized_part = part + .replace('Control', 'Ctrl') + .replace('Spacebar', 'Space') + .replace('Key', '') + if (normalized_part === ' ') return 'Space' + if (normalized_part === 'Esc') return 'Escape' + return normalized_part + }) + + const order_preference = ['Ctrl', 'Shift', 'Alt'] + keycodes.sort((a, b) => { + return order_preference.indexOf(b) - order_preference.indexOf(a) + }) + + return keycodes.join('-') + } + + + #focused_on_editable_element = () => { + const active_element = document.activeElement + if (!active_element) return false + + if (active_element instanceof HTMLInputElement) return true + if (active_element instanceof HTMLTextAreaElement) return true + if (active_element instanceof HTMLSelectElement) return true + return active_element.hasAttribute('contenteditable') + } + + public constructor(config: Config) { this.emitter = new EventTarget() this.disabled = false @@ -35,7 +81,7 @@ export class Keybinds { this.#keybind_mapper = new Map() for (const [keyboard_action, keyboard_shortcuts] of Object.entries(this.#config.web.shortcuts)) { for (const keyboard_shortcut of keyboard_shortcuts) { - this.#keybind_mapper.set(keyboard_shortcut, keyboard_action as KeybindAction) + this.#keybind_mapper.set(this.#get_keybind_code(keyboard_shortcut), keyboard_action as KeybindAction) } } } @@ -55,17 +101,18 @@ export class Keybinds { } public listen(event: KeybindAction, handler: KeybindActionListener) { - this.emitter.addEventListener(event, handler) + this.emitter.addEventListener(event, handler as EventListener) return handler } public remove_listener(event: KeybindAction, handler: KeybindActionListener) { - this.emitter.removeEventListener(event, handler) + this.emitter.removeEventListener(event, handler as EventListener) } public handler = (e: KeyboardEvent) => { if (this.disabled) return if (!this.#keybind_mapper) return + if (this.#focused_on_editable_element()) return const last_keycode = e.code .replace('Shift', '') @@ -79,8 +126,7 @@ export class Keybinds { if (e.shiftKey) keys_down.push('Shift') if (e.altKey) keys_down.push('Alt') keys_down.push(last_keycode) - const code = keys_down.join('-') - + const code = this.#get_keybind_code(keys_down.join('-')) const action = this.#keybind_mapper.get(code) if (action) { this.emitter.dispatchEvent(new CustomEvent(action, { diff --git a/packages/web/src/lib/runes/settings_rune.svelte.ts b/packages/web/src/lib/runes/settings_rune.svelte.ts index 4e335605..ffa75c32 100644 --- a/packages/web/src/lib/runes/settings_rune.svelte.ts +++ b/packages/web/src/lib/runes/settings_rune.svelte.ts @@ -5,6 +5,7 @@ import type { BaseController } from '$lib/base_controller.ts' interface MutatableSettings { 'ui.media_list.thumbnail_size': Config['web']['ui_defaults']['media_list']['thumbnail_size'] 'ui.media_list.thumbnail_shape': Config['web']['ui_defaults']['media_list']['thumbnail_shape'] + 'ui.media_view.fit.mode': Config['web']['ui_defaults']['media_view']['fit']['mode'] 'ui.search.advanced_filters.hide': Config['web']['ui_defaults']['search']['advanced_filters']['hide'] 'ui.sidebar.hide': Config['web']['ui_defaults']['sidebar']['hide'] } @@ -35,6 +36,10 @@ export class SettingsRune extends Rune { this.ui.media_list.thumbnail_shape = value break } + case 'ui.media_view.fit.mode': { + this.ui.media_view.fit.mode = value + break + } case 'ui.search.advanced_filters.hide': { this.ui.search.advanced_filters.hide = value break @@ -68,6 +73,9 @@ export class SettingsRune extends Rune { case 'ui.search.advanced_filters.hide': { return this.ui.search.advanced_filters.hide } + case 'ui.media_view.fit.mode': { + return this.ui.media_view.fit.mode + } default: { throw new Error(`Unexpected path '${path}'`) } diff --git a/packages/web/src/lib/server/config.ts b/packages/web/src/lib/server/config.ts index c3d83c0f..a41dd73c 100644 --- a/packages/web/src/lib/server/config.ts +++ b/packages/web/src/lib/server/config.ts @@ -52,6 +52,10 @@ export const PackagesConfig = z.object({ }).strict().prefault({}), media_view: z.object({ + fit: z.object({ + mode: z.enum(['original', 'fill']).default('original'), + edge: z.enum(['width', 'height']).default('height'), + }).strict().prefault({}), filmstrip: z.object({ enabled: z.boolean().default(false), thumbnail_size: z.number().default(100), diff --git a/packages/web/src/routes/browse/+page.svelte b/packages/web/src/routes/browse/+page.svelte index 78ba0a8f..f101e954 100644 --- a/packages/web/src/routes/browse/+page.svelte +++ b/packages/web/src/routes/browse/+page.svelte @@ -31,6 +31,6 @@ diff --git a/packages/web/src/routes/browse/components/Footer.svelte b/packages/web/src/routes/browse/components/Footer.svelte index 3c6445f2..a8a63399 100644 --- a/packages/web/src/routes/browse/components/Footer.svelte +++ b/packages/web/src/routes/browse/components/Footer.svelte @@ -3,7 +3,6 @@ import type { BrowseController } from '../controller.ts' let {controller, height = $bindable()}: {controller: BrowseController, height: number} = $props() - const runes = controller.runes function human_readable_number(n: number) { @@ -55,7 +54,7 @@ bind:clientHeight={height} > -
+
{ - runes.settings.set('ui.media_list.thumbnail_size', e.target.value) + controller.runes.settings.set('ui.media_list.thumbnail_size', e.target.value) }}> - {runes.settings.ui.media_list.thumbnail_size}px + {controller.runes.settings.ui.media_list.thumbnail_size}px
+ tiles: + >{controller.runes.settings.ui.media_list.thumbnail_shape} +
+ +
+ view: +
- Total results {human_readable_number(runes.media_list.total)} + Total results {human_readable_number(controller.runes.media_list.total)}
diff --git a/packages/web/src/routes/browse/components/MediaView.svelte b/packages/web/src/routes/browse/components/MediaView.svelte index 9f6ac754..23206b14 100644 --- a/packages/web/src/routes/browse/components/MediaView.svelte +++ b/packages/web/src/routes/browse/components/MediaView.svelte @@ -7,49 +7,85 @@ // TODO wire this into settings let show_controls = $state(false); + let is_fullscreen = $state(false) + + const refresh_fullscreen_state = () => { + is_fullscreen = document.fullscreenElement === fullscreen_container + } + + let {controller}: Props = $props() + let paused = $state(false) + + const media_fit_classes = $derived.by(() => { + if (controller.runes.settings.ui.media_view.fit.mode === 'original') { + return 'w-auto h-auto max-w-full max-h-full' + } + + if (controller.runes.settings.ui.media_view.fit.edge === 'width') { + return 'w-full h-auto max-h-full' + } + + return 'h-full w-auto max-w-full' + }) controller.keybinds.component_listen({ - Escape: e => { + Escape: () => { + if (document.fullscreenElement === fullscreen_container) { + document.exitFullscreen() + return + } dialog.close() controller.runes.media_selections.close_media() }, - PlayPauseMedia: e => { + PlayPauseMedia: () => { paused = !paused }, - OpenMedia: e => { + OpenMedia: () => { /* // TODO we need to be aware of the focus when making this call. Currently this will take over hitting "Enter" on the search bar media_selections.open_media() */ }, - ToggleMediaControls: async e => { + ToggleMediaControls: async () => { show_controls = !show_controls }, - CopyMedia: async e => { - if (media_selections.current_selection.media_response) { - if (media_selections.current_selection.media_response.media_type === 'media_file') { - await navigator.clipboard.writeText(media_selections.current_selection.media_response.media_file.filepath) + ToggleFitMedia: async () => { + const updated_mode = controller.runes.settings.ui.media_view.fit.mode === 'original' + ? 'fill' + : 'original' + controller.runes.settings.set('ui.media_view.fit.mode', updated_mode) + }, + ToggleFullScreen: async e => { + if (!dialog.open || !controller.runes.media_selections.current_selection.show) return + e.detail.data.keyboard_event.preventDefault() + + if (document.fullscreenElement === fullscreen_container) { + await document.exitFullscreen() + } else { + await fullscreen_container.requestFullscreen() + } + }, + CopyMedia: async () => { + if (controller.runes.media_selections.current_selection.media_response) { + if (controller.runes.media_selections.current_selection.media_response.media_type === 'media_file') { + await navigator.clipboard.writeText(controller.runes.media_selections.current_selection.media_response.media_file.filepath) } } }, }) - let {controller}: Props = $props() - const {media_selections} = controller.runes - let paused = $state(false) - let filmstrip_thumbnails let filmstrip_height = 50 $effect(() => { - if (!dialog.open && media_selections.current_selection.show) { + if (!dialog.open && controller.runes.media_selections.current_selection.show) { dialog.show() } }) function video_loader(node: HTMLVideoElement) { return { - update(video_source_url: string) { + update(_video_source_url: string) { animation_progress = 0 node.load() } @@ -57,10 +93,10 @@ } let media_url = $derived.by(() => { - if (media_selections.current_selection.media_response?.media_type !== 'media_file') { + if (controller.runes.media_selections.current_selection.media_response?.media_type !== 'media_file') { return } - return `/files/media_file/${media_selections.current_selection.media_response.media_reference.id}` + return `/files/media_file/${controller.runes.media_selections.current_selection.media_response.media_reference.id}` }) $effect(() => { if (media_url) { @@ -70,6 +106,7 @@ let dialog: HTMLDialogElement + let fullscreen_container: HTMLDivElement let animation_width = $state(0) let animation_progress = $state(0) @@ -77,22 +114,26 @@ + onclose={() => { + refresh_fullscreen_state() + controller.runes.media_selections.close_media() + }}>
- {#if media_selections.current_selection.show && media_selections.current_selection.media_response} - {#if media_selections.current_selection.media_response.media_type === 'media_file'} - {#if media_selections.current_selection.media_response.media_file.media_type === 'IMAGE'} + {#if controller.runes.media_selections.current_selection.show && controller.runes.media_selections.current_selection.media_response} + {#if controller.runes.media_selections.current_selection.media_response.media_type === 'media_file'} + {#if controller.runes.media_selections.current_selection.media_response.media_file.media_type === 'IMAGE'} - {:else if media_selections.current_selection.media_response.media_file.media_type === 'VIDEO'} + {:else if controller.runes.media_selections.current_selection.media_response.media_file.media_type === 'VIDEO'}
{/if} - {:else if media_selections.current_selection.media_response.media_file.media_type === 'AUDIO'} + {:else if controller.runes.media_selections.current_selection.media_response.media_file.media_type === 'AUDIO'} Audio thumbnail + class="object-contain {media_fit_classes}" + src="/files/thumbnail/{controller.runes.media_selections.current_selection.media_response.media_reference.id}" alt="Audio thumbnail">
+ +