mirror of
https://github.com/open-webui/open-webui.git
synced 2026-11-15 09:31:21 +00:00
refac
This commit is contained in:
parent
6dd3cc216f
commit
c4eb444a04
6 changed files with 108 additions and 76 deletions
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
32
src/lib/utils/selectionToolbar.ts
Normal file
32
src/lib/utils/selectionToolbar.ts
Normal 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() };
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue