mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-14 05:15:18 +00:00
feat: Add adjustable text size setting to interface (#19186)
* Add adjustable text size setting to interface Introduces a user-configurable text size (scale) setting, accessible via a slider in the interface settings. Updates CSS and Sidebar chat item components to respect the new --app-text-scale variable, and persists the setting in the store. Adds related i18n strings and ensures the text scale is applied globally and clamped to allowed values. * Refactor text scale logic into utility module Moved all text scale related constants and functions from components and stores into a new utility module (src/lib/utils/text-scale.ts). Updated imports and usage in Interface.svelte and index.ts to use the new module, improving code organization and reusability. * Adjust sidebar chat scaling without extra classes keep sidebar markup using existing Tailwind utility classes so chat items render identically pre-feature move all text-scale sizing into app.css under the #sidebar-chat-item selectors change the root font-size multiplier to use 1rem instead of an explicit 16px so browser/user preferences propagate * Update Switch.svelte Adjust toggles from fixed pixel to rem to scale with the text size * Update Interface.svelte Updated label from 'Text Scale' to 'UI Scale'. Added padding around slider * Update app.css Added comments
This commit is contained in:
parent
720af637e6
commit
7762fa5ddf
7 changed files with 216 additions and 5 deletions
25
src/app.css
25
src/app.css
|
|
@ -30,8 +30,33 @@
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --app-text-scale is updated via the UI Scale slider (Interface.svelte) */
|
||||||
|
:root {
|
||||||
|
--app-text-scale: 1;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
/* font-size scales the entire document via the same UI control */
|
||||||
|
font-size: calc(1rem * var(--app-text-scale, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar-chat-item {
|
||||||
|
/* sidebar item sizing scales for the chat list entries */
|
||||||
|
min-height: calc(32px * var(--app-text-scale, 1));
|
||||||
|
padding-inline: calc(11px * var(--app-text-scale, 1));
|
||||||
|
padding-block: calc(6px * var(--app-text-scale, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar-chat-item div[dir='auto'] {
|
||||||
|
/* chat title line height follows the text scale */
|
||||||
|
height: calc(20px * var(--app-text-scale, 1));
|
||||||
|
line-height: calc(20px * var(--app-text-scale, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar-chat-item input {
|
||||||
|
/* editing state input height is kept in sync */
|
||||||
|
min-height: calc(20px * var(--app-text-scale, 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { config, models, settings, user } from '$lib/stores';
|
import { config, models, settings, user } from '$lib/stores';
|
||||||
import { createEventDispatcher, onMount, getContext } from 'svelte';
|
import { createEventDispatcher, onMount, onDestroy, getContext } from 'svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
import Tooltip from '$lib/components/common/Tooltip.svelte';
|
||||||
import { updateUserInfo } from '$lib/apis/users';
|
import { updateUserInfo } from '$lib/apis/users';
|
||||||
|
|
@ -10,6 +10,14 @@
|
||||||
import Switch from '$lib/components/common/Switch.svelte';
|
import Switch from '$lib/components/common/Switch.svelte';
|
||||||
import ManageFloatingActionButtonsModal from './Interface/ManageFloatingActionButtonsModal.svelte';
|
import ManageFloatingActionButtonsModal from './Interface/ManageFloatingActionButtonsModal.svelte';
|
||||||
import ManageImageCompressionModal from './Interface/ManageImageCompressionModal.svelte';
|
import ManageImageCompressionModal from './Interface/ManageImageCompressionModal.svelte';
|
||||||
|
import {
|
||||||
|
DEFAULT_TEXT_SCALE_INDEX,
|
||||||
|
TEXT_SCALE_MAX,
|
||||||
|
TEXT_SCALE_MIN,
|
||||||
|
TEXT_SCALE_VALUES,
|
||||||
|
findClosestTextScaleIndex,
|
||||||
|
getScaleFromIndex
|
||||||
|
} from '$lib/utils/text-scale';
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
const i18n = getContext('i18n');
|
const i18n = getContext('i18n');
|
||||||
|
|
@ -97,6 +105,46 @@
|
||||||
let showManageFloatingActionButtonsModal = false;
|
let showManageFloatingActionButtonsModal = false;
|
||||||
let showManageImageCompressionModal = false;
|
let showManageImageCompressionModal = false;
|
||||||
|
|
||||||
|
let textScaleIndex = DEFAULT_TEXT_SCALE_INDEX;
|
||||||
|
let unsubscribeTextScale: (() => void) | undefined;
|
||||||
|
|
||||||
|
const persistTextScale = () => {
|
||||||
|
const scale = getScaleFromIndex(textScaleIndex);
|
||||||
|
|
||||||
|
if ($settings?.textScale === scale) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveSettings({ textScale: scale });
|
||||||
|
};
|
||||||
|
|
||||||
|
const decreaseTextScale = () => {
|
||||||
|
const previous = textScaleIndex;
|
||||||
|
textScaleIndex = Math.max(0, textScaleIndex - 1);
|
||||||
|
|
||||||
|
if (textScaleIndex === previous) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
persistTextScale();
|
||||||
|
};
|
||||||
|
|
||||||
|
const increaseTextScale = () => {
|
||||||
|
const previous = textScaleIndex;
|
||||||
|
textScaleIndex = Math.min(TEXT_SCALE_VALUES.length - 1, textScaleIndex + 1);
|
||||||
|
|
||||||
|
if (textScaleIndex === previous) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
persistTextScale();
|
||||||
|
};
|
||||||
|
|
||||||
|
$: currentTextScale = getScaleFromIndex(textScaleIndex);
|
||||||
|
$: textScaleDisplay = Number.isInteger(currentTextScale)
|
||||||
|
? `${currentTextScale.toFixed(0)}`
|
||||||
|
: `${currentTextScale.toFixed(1)}`;
|
||||||
|
|
||||||
const toggleLandingPageMode = async () => {
|
const toggleLandingPageMode = async () => {
|
||||||
landingPageMode = landingPageMode === '' ? 'chat' : '';
|
landingPageMode = landingPageMode === '' ? 'chat' : '';
|
||||||
saveSettings({ landingPageMode: landingPageMode });
|
saveSettings({ landingPageMode: landingPageMode });
|
||||||
|
|
@ -252,6 +300,20 @@
|
||||||
|
|
||||||
backgroundImageUrl = $settings?.backgroundImageUrl ?? null;
|
backgroundImageUrl = $settings?.backgroundImageUrl ?? null;
|
||||||
webSearch = $settings?.webSearch ?? null;
|
webSearch = $settings?.webSearch ?? null;
|
||||||
|
|
||||||
|
textScaleIndex = findClosestTextScaleIndex($settings?.textScale ?? 1);
|
||||||
|
|
||||||
|
unsubscribeTextScale = settings.subscribe((uiSettings) => {
|
||||||
|
const nextScaleIndex = findClosestTextScaleIndex(uiSettings?.textScale ?? 1);
|
||||||
|
|
||||||
|
if (nextScaleIndex !== textScaleIndex) {
|
||||||
|
textScaleIndex = nextScaleIndex;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
unsubscribeTextScale?.();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -331,6 +393,64 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="py-0.5">
|
||||||
|
<div class="flex w-full justify-between">
|
||||||
|
<label
|
||||||
|
id="ui-scale-label"
|
||||||
|
class=" self-center text-xs"
|
||||||
|
for="ui-scale-slider"
|
||||||
|
>
|
||||||
|
{$i18n.t('UI Scale')}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 text-xs" aria-live="polite">
|
||||||
|
<span>{textScaleDisplay}x</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-2 flex items-center gap-2 pl-1 pr-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-sm p-1 transition outline outline-1 outline-gray-200 hover:bg-gray-100 dark:outline-gray-700 dark:hover:bg-gray-800"
|
||||||
|
on:click={decreaseTextScale}
|
||||||
|
aria-labelledby="ui-scale-label"
|
||||||
|
aria-label={$i18n.t('Decrease UI Scale')}
|
||||||
|
>
|
||||||
|
<Minus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<input
|
||||||
|
id="ui-scale-slider"
|
||||||
|
class="w-full accent-black dark:accent-white"
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={TEXT_SCALE_VALUES.length - 1}
|
||||||
|
step={1}
|
||||||
|
bind:value={textScaleIndex}
|
||||||
|
on:change={persistTextScale}
|
||||||
|
aria-labelledby="ui-scale-label"
|
||||||
|
aria-valuemin={TEXT_SCALE_MIN}
|
||||||
|
aria-valuemax={TEXT_SCALE_MAX}
|
||||||
|
aria-valuenow={currentTextScale}
|
||||||
|
aria-valuetext={`${textScaleDisplay}x`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-sm p-1 transition outline outline-1 outline-gray-200 hover:bg-gray-100 dark:outline-gray-700 dark:hover:bg-gray-800"
|
||||||
|
on:click={increaseTextScale}
|
||||||
|
aria-labelledby="ui-scale-label"
|
||||||
|
aria-label={$i18n.t('Increase UI Scale')}
|
||||||
|
>
|
||||||
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div class=" py-0.5 flex w-full justify-between">
|
<div class=" py-0.5 flex w-full justify-between">
|
||||||
<div id="use-chat-title-as-tab-title-label" class=" self-center text-xs">
|
<div id="use-chat-title-as-tab-title-label" class=" self-center text-xs">
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
bind:checked={state}
|
bind:checked={state}
|
||||||
{id}
|
{id}
|
||||||
aria-labelledby={ariaLabelledbyId}
|
aria-labelledby={ariaLabelledbyId}
|
||||||
class="flex h-[18px] min-h-[18px] w-8 shrink-0 cursor-pointer items-center rounded-full px-1 mx-[1px] transition {($settings?.highContrastMode ??
|
class="flex h-[1.125rem] min-h-[1.125rem] w-8 shrink-0 cursor-pointer items-center rounded-full px-1 mx-[1px] transition {($settings?.highContrastMode ??
|
||||||
false)
|
false)
|
||||||
? 'focus:outline focus:outline-2 focus:outline-gray-800 focus:dark:outline-gray-200'
|
? 'focus:outline focus:outline-2 focus:outline-gray-800 focus:dark:outline-gray-200'
|
||||||
: 'outline outline-1 outline-gray-100 dark:outline-gray-800'} {state
|
: 'outline outline-1 outline-gray-100 dark:outline-gray-800'} {state
|
||||||
|
|
|
||||||
|
|
@ -401,8 +401,7 @@
|
||||||
{:else}
|
{:else}
|
||||||
<a
|
<a
|
||||||
id="sidebar-chat-item"
|
id="sidebar-chat-item"
|
||||||
class=" w-full flex justify-between rounded-xl px-[11px] py-[6px] {id === $chatId ||
|
class=" w-full flex justify-between rounded-xl px-[11px] py-[6px] {id === $chatId || confirmEdit
|
||||||
confirmEdit
|
|
||||||
? 'bg-gray-100 dark:bg-gray-900 selected'
|
? 'bg-gray-100 dark:bg-gray-900 selected'
|
||||||
: selected
|
: selected
|
||||||
? 'bg-gray-100 dark:bg-gray-950 selected'
|
? 'bg-gray-100 dark:bg-gray-950 selected'
|
||||||
|
|
@ -436,7 +435,7 @@
|
||||||
draggable="false"
|
draggable="false"
|
||||||
>
|
>
|
||||||
<div class=" flex self-center flex-1 w-full">
|
<div class=" flex self-center flex-1 w-full">
|
||||||
<div dir="auto" class="text-left self-center overflow-hidden w-full h-[20px] truncate">
|
<div dir="auto" class=" text-left self-center overflow-hidden w-full h-[20px] truncate">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,9 @@
|
||||||
"Hide Model": "",
|
"Hide Model": "",
|
||||||
"High": "",
|
"High": "",
|
||||||
"High Contrast Mode": "",
|
"High Contrast Mode": "",
|
||||||
|
"Decrease Text Size": "",
|
||||||
|
"Increase Text Size": "",
|
||||||
|
"Text Size": "",
|
||||||
"Home": "",
|
"Home": "",
|
||||||
"Host": "",
|
"Host": "",
|
||||||
"How can I help you today?": "",
|
"How can I help you today?": "",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import type { Banner } from '$lib/types';
|
||||||
import type { Socket } from 'socket.io-client';
|
import type { Socket } from 'socket.io-client';
|
||||||
|
|
||||||
import emojiShortCodes from '$lib/emoji-shortcodes.json';
|
import emojiShortCodes from '$lib/emoji-shortcodes.json';
|
||||||
|
import { resolveTextScale, setDocumentTextScale } from '$lib/utils/text-scale';
|
||||||
|
|
||||||
// Backend
|
// Backend
|
||||||
export const WEBUI_NAME = writable(APP_NAME);
|
export const WEBUI_NAME = writable(APP_NAME);
|
||||||
|
|
@ -68,6 +69,13 @@ export const banners: Writable<Banner[]> = writable([]);
|
||||||
|
|
||||||
export const settings: Writable<Settings> = writable({});
|
export const settings: Writable<Settings> = writable({});
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
settings.subscribe(($settings) => {
|
||||||
|
const clampedScale = resolveTextScale($settings?.textScale ?? 1);
|
||||||
|
setDocumentTextScale(clampedScale);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const audioQueue = writable(null);
|
export const audioQueue = writable(null);
|
||||||
|
|
||||||
export const showSidebar = writable(false);
|
export const showSidebar = writable(false);
|
||||||
|
|
@ -161,6 +169,7 @@ type Settings = {
|
||||||
notifications?: any;
|
notifications?: any;
|
||||||
imageCompression?: boolean;
|
imageCompression?: boolean;
|
||||||
imageCompressionSize?: any;
|
imageCompressionSize?: any;
|
||||||
|
textScale?: number;
|
||||||
widescreenMode?: null;
|
widescreenMode?: null;
|
||||||
largeTextAsFile?: boolean;
|
largeTextAsFile?: boolean;
|
||||||
promptAutocomplete?: boolean;
|
promptAutocomplete?: boolean;
|
||||||
|
|
|
||||||
55
src/lib/utils/text-scale.ts
Normal file
55
src/lib/utils/text-scale.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
const TEXT_SCALE_VALUES = [1, 1.1, 1.2, 1.3, 1.4, 1.5] as const;
|
||||||
|
|
||||||
|
export type TextScale = (typeof TEXT_SCALE_VALUES)[number];
|
||||||
|
|
||||||
|
export const TEXT_SCALE_MIN = TEXT_SCALE_VALUES[0];
|
||||||
|
export const TEXT_SCALE_MAX = TEXT_SCALE_VALUES[TEXT_SCALE_VALUES.length - 1];
|
||||||
|
|
||||||
|
export const DEFAULT_TEXT_SCALE: TextScale = 1;
|
||||||
|
export const DEFAULT_TEXT_SCALE_INDEX = TEXT_SCALE_VALUES.findIndex(
|
||||||
|
(scale) => scale === DEFAULT_TEXT_SCALE
|
||||||
|
);
|
||||||
|
|
||||||
|
export const getScaleFromIndex = (index: number): TextScale => {
|
||||||
|
if (!Number.isFinite(index)) {
|
||||||
|
return TEXT_SCALE_VALUES[DEFAULT_TEXT_SCALE_INDEX];
|
||||||
|
}
|
||||||
|
|
||||||
|
return TEXT_SCALE_VALUES[index] ?? TEXT_SCALE_VALUES[DEFAULT_TEXT_SCALE_INDEX];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const findClosestTextScaleIndex = (value: unknown): number => {
|
||||||
|
const numeric = Number(value);
|
||||||
|
|
||||||
|
if (!Number.isFinite(numeric)) {
|
||||||
|
return DEFAULT_TEXT_SCALE_INDEX;
|
||||||
|
}
|
||||||
|
|
||||||
|
let closestIndex = DEFAULT_TEXT_SCALE_INDEX;
|
||||||
|
let smallestDistance = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
TEXT_SCALE_VALUES.forEach((scale, idx) => {
|
||||||
|
const distance = Math.abs(scale - numeric);
|
||||||
|
|
||||||
|
if (distance < smallestDistance) {
|
||||||
|
closestIndex = idx;
|
||||||
|
smallestDistance = distance;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return closestIndex;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveTextScale = (value: unknown): TextScale => {
|
||||||
|
return TEXT_SCALE_VALUES[findClosestTextScaleIndex(value)] ?? DEFAULT_TEXT_SCALE;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setDocumentTextScale = (scale: TextScale) => {
|
||||||
|
if (typeof document === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.documentElement.style.setProperty('--app-text-scale', scale.toString());
|
||||||
|
};
|
||||||
|
|
||||||
|
export { TEXT_SCALE_VALUES };
|
||||||
Loading…
Reference in a new issue