This commit is contained in:
Timothy Jaeryang Baek 2026-11-02 11:09:51 +09:00
parent 6dd3cc216f
commit c4eb444a04
6 changed files with 108 additions and 76 deletions

View file

@ -1,5 +1,6 @@
<script lang="ts">
import { getContext, tick } from 'svelte';
import { positionSelectionToolbar } from '$lib/utils/selectionToolbar';
const i18n = getContext<typeof import('$lib/i18n').default>('i18n');
import ChatBubble from '$lib/components/icons/ChatBubble.svelte';
@ -7,6 +8,8 @@
import LightBulb from '$lib/components/icons/LightBulb.svelte';
export let id = '';
export let range: Range | null = null;
export let selectedText = '';
export let onComment: (() => void) | null = null;
export let actions = [];
@ -15,7 +18,6 @@
let floatingInput = false;
let selectedAction = null;
let selectedText = '';
let floatingInputValue = '';
const portal = (node: HTMLElement) => {
@ -86,7 +88,6 @@
export const closeHandler = () => {
selectedAction = null;
selectedText = '';
floatingInput = false;
floatingInputValue = '';
};
@ -94,20 +95,20 @@
<div
use:portal
use:positionSelectionToolbar={range}
id={`floating-buttons-${id}`}
class="fixed rounded-lg mt-1 text-xs z-9999"
style="display: none"
class="fixed max-w-[calc(100vw-1rem)] rounded-lg text-xs z-9999"
style:display={range ? undefined : 'none'}
>
{#if !floatingInput}
<div
class="flex flex-row shrink-0 p-0.5 bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-xl shadow-xl border border-gray-100 dark:border-gray-800"
class="flex flex-row flex-wrap shrink-0 p-0.5 bg-white dark:bg-gray-850 dark:text-gray-100 text-medium rounded-xl shadow-xl border border-gray-100 dark:border-gray-800"
>
{#each displayActions as action}
<button
aria-label={action.label}
class="px-1.5 py-[0.0625rem] hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl flex items-center gap-1 min-w-fit transition"
class="px-1.5 py-[0.0625rem] min-h-9 sm:min-h-0 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl flex items-center gap-1 min-w-fit transition"
on:click={async () => {
selectedText = window.getSelection().toString();
selectedAction = action;
if (action.prompt.includes('{{INPUT_CONTENT}}')) {
@ -135,7 +136,7 @@
{#if onComment}<button
type="button"
aria-label={$i18n.t('Comment')}
class="px-1.5 py-[0.0625rem] hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl flex items-center gap-1 min-w-fit transition"
class="px-1.5 py-[0.0625rem] min-h-9 sm:min-h-0 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl flex items-center gap-1 min-w-fit transition"
on:mousedown|preventDefault
on:click={onComment}
><Annotation className="size-3 shrink-0" /><span>{$i18n.t('Comment')}</span></button
@ -143,12 +144,12 @@
</div>
{:else}
<div
class="py-1 flex dark:text-gray-100 bg-white dark:bg-gray-850 border border-gray-100 dark:border-gray-800 w-72 rounded-full shadow-xl"
class="py-1 flex dark:text-gray-100 bg-white dark:bg-gray-850 border border-gray-100 dark:border-gray-800 w-72 max-w-full rounded-full shadow-xl"
>
<input
type="text"
id="floating-message-input"
class="ml-5 bg-transparent outline-hidden w-full flex-1 text-sm"
class="ml-5 min-w-0 bg-transparent outline-hidden w-full flex-1 text-sm"
placeholder={$i18n.t('Ask a question')}
aria-label={$i18n.t('Ask a question')}
bind:value={floatingInputValue}

View file

@ -92,6 +92,7 @@
} | null = null;
// Keep typing from invalidating the keyed selection and remounting the input.
let fileAskInput = '';
let usingFileAsk = false;
let asking = false;
const positionFileAsk = (node: HTMLFormElement, anchor: Range | HTMLElement) => {
document.body.appendChild(node);
@ -192,6 +193,7 @@
};
};
const captureSelection = () => {
if (!active || usingFileAsk) return;
const selection = window.getSelection();
if (!onAskFile || !openedFile || !selection?.rangeCount || selection.isCollapsed) return;
const range = selection.getRangeAt(0);
@ -2014,6 +2016,13 @@
}}
/>
<svelte:document
on:selectionchange={captureSelection}
on:focusin={(event) => {
usingFileAsk = !!(event.target as Element)?.closest('[data-file-ask]');
}}
/>
<svelte:window
on:keydown={(event) => {
if (!active) return;
@ -2023,9 +2032,9 @@
} else handleKeydown(event);
}}
on:click={handleWindowClick}
on:pointerdown={(event) => {
if (fileAsk && !dirtyChoice && !(event.target as Element)?.closest('[data-file-ask]'))
fileAsk = null;
on:pointerdown|capture={(event) => {
usingFileAsk = !!(event.target as Element)?.closest('[data-file-ask]');
if (fileAsk && !dirtyChoice && !usingFileAsk) fileAsk = null;
}}
/>
@ -2368,8 +2377,6 @@
<!-- Content -->
<div
bind:this={viewerRoot}
on:mouseup={captureSelection}
on:keyup={captureSelection}
class="flex-1 overflow-y-auto min-h-0 min-w-0"
on:click={(e) => {
closeDirectoryMenu();

View file

@ -105,9 +105,14 @@
export let onToolCallResolved = (e) => {};
export let onSetInputText = (text) => {};
/** @type {HTMLDivElement | undefined} */
let contentContainerElement;
/** @type {{ closeHandler: () => void } | undefined} */
let floatingButtonsElement;
/** @type {Range | null} */
let floatingButtonsRange = null;
let selectedText = '';
let usingFloatingButtons = false;
let sourceIds = [];
$: getSourceIds(sources);
@ -192,65 +197,41 @@
}
);
const updateButtonPosition = (event) => {
const buttonsContainerElement = document.getElementById(`floating-buttons-${id}`);
const updateSelection = () => {
if (usingFloatingButtons) return;
const selection = window.getSelection();
const range = selection?.rangeCount ? selection.getRangeAt(0) : null;
if (
!contentContainerElement?.contains(event.target) &&
!buttonsContainerElement?.contains(event.target)
!selection ||
!range ||
!selection.toString().trim() ||
!contentContainerElement?.contains(range.startContainer) ||
!contentContainerElement.contains(range.endContainer)
) {
closeFloatingButtons();
return;
}
setTimeout(async () => {
await tick();
if (!contentContainerElement?.contains(event.target)) return;
let selection = window.getSelection();
if (selection.toString().trim().length > 0) {
floatingButtonsRange = selection.getRangeAt(0);
commentSelection = $config?.features?.enable_comments && $user ? selectedComment() : null;
positionFloatingButtons();
} else {
closeFloatingButtons();
}
}, 0);
floatingButtonsElement?.closeHandler();
selectedText = selection.toString();
floatingButtonsRange = range.cloneRange();
commentSelection = $config?.features?.enable_comments && $user ? selectedComment() : null;
};
const positionFloatingButtons = () => {
const buttonsContainerElement = document.getElementById(`floating-buttons-${id}`);
if (!floatingButtonsRange || !buttonsContainerElement) return;
const rect = floatingButtonsRange.getBoundingClientRect();
const parentRect = contentContainerElement.getBoundingClientRect();
buttonsContainerElement.style.display = 'block';
// Calculate space available on the right
const spaceOnRight = parentRect.right - rect.left;
let halfScreenWidth = $mobile ? window.innerWidth / 2 : window.innerWidth / 3;
if (spaceOnRight < halfScreenWidth) {
const right = document.documentElement.clientWidth - rect.right;
buttonsContainerElement.style.right = `${right}px`;
buttonsContainerElement.style.left = 'auto'; // Reset left
} else {
// Enough space, position using 'left'
buttonsContainerElement.style.left = `${rect.left}px`;
buttonsContainerElement.style.right = 'auto'; // Reset right
/** @param {Event} event */
const toolbarInteractionHandler = (event) => {
if (!(event.target instanceof Node)) return;
const toolbar = document.getElementById(`floating-buttons-${id}`);
// Keep the snapshot while a touch focuses an action or its question input.
usingFloatingButtons = !!toolbar?.contains(event.target);
if (!usingFloatingButtons && !contentContainerElement?.contains(event.target)) {
closeFloatingButtons();
}
buttonsContainerElement.style.top = `${rect.bottom + 5}px`; // +5 to add some spacing
};
const closeFloatingButtons = () => {
commentSelection = null;
floatingButtonsRange = null;
const buttonsContainerElement = document.getElementById(`floating-buttons-${id}`);
if (buttonsContainerElement) {
buttonsContainerElement.style.display = 'none';
}
selectedText = '';
if (floatingButtonsElement) {
// check if closeHandler is defined
@ -274,8 +255,9 @@
function attachListeners() {
if (!listenersAttached && contentContainerElement) {
contentContainerElement.addEventListener('mouseup', updateButtonPosition);
document.addEventListener('mouseup', updateButtonPosition);
document.addEventListener('selectionchange', updateSelection);
document.addEventListener('pointerdown', toolbarInteractionHandler, true);
document.addEventListener('focusin', toolbarInteractionHandler, true);
document.addEventListener('keydown', keydownHandler);
listenersAttached = true;
}
@ -283,8 +265,9 @@
function detachListeners() {
if (listenersAttached) {
contentContainerElement?.removeEventListener('mouseup', updateButtonPosition);
document.removeEventListener('mouseup', updateButtonPosition);
document.removeEventListener('selectionchange', updateSelection);
document.removeEventListener('pointerdown', toolbarInteractionHandler, true);
document.removeEventListener('focusin', toolbarInteractionHandler, true);
document.removeEventListener('keydown', keydownHandler);
listenersAttached = false;
}
@ -301,8 +284,6 @@
});
</script>
<svelte:window on:scroll|capture={positionFloatingButtons} on:resize={positionFloatingButtons} />
<div bind:this={contentContainerElement}>
{#if output?.length}
<StructuredOutputRenderer
@ -408,6 +389,8 @@
{#if floatingButtons}
<FloatingButtons
bind:this={floatingButtonsElement}
range={floatingButtonsRange}
{selectedText}
{id}
actions={$settings?.floatingActionButtons ?? []}
onComment={commentSelection

View file

@ -6,6 +6,7 @@
import CommentHighlights from './CommentHighlights.svelte';
import { selectedComment, commentSelectionDraft } from './anchors';
import { toast } from 'svelte-sonner';
import { positionSelectionToolbar } from '$lib/utils/selectionToolbar';
const i18n = getContext<any>('i18n');
let selection: ReturnType<typeof selectedComment> = null;
let account: string | null = null;
@ -23,13 +24,15 @@
!$page.url.pathname.startsWith('/s/')
);
let preparing = false;
let selectionButton: HTMLButtonElement;
let usingSelectionButton = false;
$: if (($user?.id ?? null) !== account) {
account = $user?.id ?? null;
commentSelectionDraft.set(null);
selection = null;
}
const select = () => {
if (!preparing) selection = commentsAllowed ? selectedComment() : null;
if (!preparing && !usingSelectionButton) selection = commentsAllowed ? selectedComment() : null;
};
const commentSelection = async () => {
const selected = selection;
@ -50,15 +53,21 @@
}
};
onMount(() => {
document.addEventListener('mouseup', select);
document.addEventListener('keyup', select);
document.addEventListener('selectionchange', select);
return () => {
document.removeEventListener('mouseup', select);
document.removeEventListener('keyup', select);
document.removeEventListener('selectionchange', select);
};
});
</script>
<svelte:document
on:focusin={(event) => {
usingSelectionButton = !!selectionButton?.contains(event.target as Node);
}}
on:pointerdown|capture={(event) => {
usingSelectionButton = !!selectionButton?.contains(event.target as Node);
}}
/>
<svelte:window
on:keydown={(event) => {
if (event.key === 'Escape') {
@ -70,15 +79,15 @@
selection = null;
}
}}
on:scroll|capture={() => (selection = null)}
/>
{#if commentsAllowed}
{#key account}<CommentHighlights />{/key}
{#if selection && !selection.options.toolbar_id}
<button
bind:this={selectionButton}
use:positionSelectionToolbar={selection.range}
type="button"
class="fixed z-9999 flex items-center gap-1 rounded-xl border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850 px-2 py-1 text-xs shadow-xl"
style={`left:${Math.max(8, Math.min(selection.rect.left, window.innerWidth - 130))}px;top:${Math.min(window.innerHeight - 48, selection.rect.bottom + 12)}px`}
class="fixed z-9999 min-h-9 sm:min-h-0 flex items-center gap-1 rounded-xl border border-gray-100 dark:border-gray-800 bg-white dark:bg-gray-850 px-2 py-1 text-xs shadow-xl"
on:mousedown|preventDefault
disabled={preparing}
on:click={commentSelection}><Annotation className="size-3" />{$i18n.t('Comment')}</button

View file

@ -147,7 +147,7 @@ export function selectedComment() {
source: sha256(text),
content_hash: options.content_hash
};
return { options, anchor, rect: range.getBoundingClientRect() };
return { options, anchor, range, rect: range.getBoundingClientRect() };
}
return null;
}

View file

@ -0,0 +1,32 @@
import { autoUpdate, computePosition, flip, offset, shift } from '@floating-ui/dom';
// Range geometry follows scrolling; Floating UI also accounts for the on-screen keyboard.
export function positionSelectionToolbar(node: HTMLElement, range: Range | null) {
let cleanup = () => {};
const update = (range: Range | null) => {
cleanup();
if (!range) return;
let active = true;
const reference = {
getBoundingClientRect: () => range.getBoundingClientRect(),
contextElement: range.commonAncestorContainer.parentElement ?? undefined
};
const stop = autoUpdate(reference, node, () => {
void computePosition(reference, node, {
strategy: 'fixed',
placement: 'bottom-start',
middleware: [offset(12), flip(), shift({ padding: 8 })]
}).then(({ x, y }) => {
if (!active) return;
node.style.left = `${x}px`;
node.style.top = `${y}px`;
});
});
cleanup = () => {
active = false;
stop();
};
};
update(range);
return { update, destroy: () => cleanup() };
}