Files
2026-07-13 12:34:04 +08:00

2171 lines
76 KiB
JavaScript

'use strict';
const {
t: themeT,
} = globalThis.TabHarborI18n || {};
const {
escapeHtml: themeEscapeHtml,
escapeHtmlAttribute: themeEscapeHtmlAttribute,
getFallbackLabel: themeGetFallbackLabel,
getIconSources: themeGetIconSources,
} = globalThis.TabOutIconUtils || {};
const {
compressImageFileForStorage: themeCompressImageFileForStorage,
} = globalThis.TabOutBackgroundImage || {};
const {
reorderSubsetByIds: themeReorderSubsetByIds,
} = globalThis.TabOutListOrder || {};
let themeMenuOpen = false;
let themeMenuActiveTab = 'appearance';
let shortcutEditorState = {
open: false,
mode: 'create',
shortcutId: '',
url: '',
label: '',
icon: '',
iconKind: '',
presentation: 'default',
returnToTabPicker: false,
focusReturnEl: null,
};
let quickShortcutDragState = null;
let quickShortcutDraggedId = '';
let quickShortcutDraggedEl = null;
let quickShortcutGhostEl = null;
let quickShortcutSlotEl = null;
let quickShortcutSuppressClickUntil = 0;
// ---- Tab Picker state ----
let tabPickerOpen = false;
let tabPickerMode = 'tabs';
let tabPickerSearchQuery = '';
let tabPickerSelectedIds = new Set();
let tabPickerFocusReturnEl = null;
let tabPickerManualDraft = { url: '', label: '' };
const THEME_PREFERENCES_KEY = 'themePreferences';
const QUICK_SHORTCUTS_KEY = 'quickShortcuts';
const FOCUSABLE_SELECTOR = [
'button:not([disabled]):not([hidden])',
'[href]:not([hidden])',
'input:not([disabled]):not([type="hidden"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
const THEME_MODE_ORDER = ['system', 'light', 'dark'];
const THEME_PALETTE_ORDER = ['paper', 'sage', 'mist', 'blush'];
const SAVED_SESSION_RESTORE_MODE_ORDER = ['current-window', 'new-window'];
const SAVED_SESSION_NAV_DISPLAY_MODE_ORDER = ['icon', 'name'];
const VALID_THEME_MODES = new Set(THEME_MODE_ORDER);
const VALID_THEME_PALETTES = new Set(THEME_PALETTE_ORDER);
const VALID_SAVED_SESSION_RESTORE_MODES = new Set(SAVED_SESSION_RESTORE_MODE_ORDER);
const VALID_SAVED_SESSION_NAV_DISPLAY_MODES = new Set(SAVED_SESSION_NAV_DISPLAY_MODE_ORDER);
const THEME_MODE_LABEL_KEYS = {
system: 'themeModeSystem',
light: 'themeModeLight',
dark: 'themeModeDark',
};
const LEGACY_THEME_MIGRATION = {
paper: { mode: 'light', paletteId: 'paper' },
sage: { mode: 'light', paletteId: 'sage' },
mist: { mode: 'light', paletteId: 'mist' },
blush: { mode: 'light', paletteId: 'blush' },
midnight: { mode: 'dark', paletteId: 'mist' },
};
const THEME_FAMILIES = {
paper: {
name: 'Paper',
meta: 'Warm neutral',
light: {
'--ink': '#1a1613',
'--paper': '#f8f5f0',
'--warm-gray': '#e8e2da',
'--muted': '#9a918a',
'--accent-amber': '#c8713a',
'--accent-sage': '#5a7a62',
'--accent-slate': '#5a6b7a',
'--accent-rose': '#b35a5a',
'--workspace-accent': '#8a653f',
'--workspace-accent-soft': '#f2e7db',
'--workspace-accent-border': '#d4b396',
'--workspace-accent-contrast': '#fffaf5',
'--status-active': '#3d7a4a',
'--status-cooling': '#b8892e',
'--status-abandoned': '#b35a5a',
'--card-bg': '#fffdf9',
},
dark: {
'--ink': '#e8e2da',
'--paper': '#1a1613',
'--warm-gray': '#2d2722',
'--muted': '#7a726a',
'--accent-amber': '#d4854a',
'--accent-sage': '#6a8a72',
'--accent-slate': '#6a7b8a',
'--accent-rose': '#b37a7a',
'--workspace-accent': '#a0754f',
'--workspace-accent-soft': '#2d2722',
'--workspace-accent-border': '#5a4a3a',
'--workspace-accent-contrast': '#f8f5f0',
'--status-active': '#5a9a6a',
'--status-cooling': '#c89a3a',
'--status-abandoned': '#c36a6a',
'--card-bg': '#231f1a',
},
},
sage: {
name: 'Sage',
meta: 'Soft green',
light: {
'--ink': '#172018',
'--paper': '#eef2eb',
'--warm-gray': '#dbe3d7',
'--muted': '#7f8c81',
'--accent-amber': '#8b7146',
'--accent-sage': '#4d6f57',
'--accent-slate': '#5e7072',
'--accent-rose': '#9a6860',
'--workspace-accent': '#4f7657',
'--workspace-accent-soft': '#deebe1',
'--workspace-accent-border': '#9ebda6',
'--workspace-accent-contrast': '#f6fbf7',
'--status-active': '#446953',
'--status-cooling': '#907548',
'--status-abandoned': '#996760',
'--card-bg': '#fafcf8',
},
dark: {
'--ink': '#d8e3d7',
'--paper': '#172018',
'--warm-gray': '#252d25',
'--muted': '#6a7a6a',
'--accent-amber': '#9a8a5a',
'--accent-sage': '#6a9a7a',
'--accent-slate': '#6a7a8a',
'--accent-rose': '#9a7a7a',
'--workspace-accent': '#5a8a6a',
'--workspace-accent-soft': '#252d25',
'--workspace-accent-border': '#3a4a3a',
'--workspace-accent-contrast': '#eef2eb',
'--status-active': '#5a9a7a',
'--status-cooling': '#b89a4a',
'--status-abandoned': '#c36a6a',
'--card-bg': '#1e261e',
},
},
mist: {
name: 'Mist',
meta: 'Cool neutral',
light: {
'--ink': '#161c21',
'--paper': '#eef2f5',
'--warm-gray': '#d8dee5',
'--muted': '#7d8791',
'--accent-amber': '#927255',
'--accent-sage': '#5d7569',
'--accent-slate': '#4f687a',
'--accent-rose': '#9b6b71',
'--workspace-accent': '#4f6d88',
'--workspace-accent-soft': '#dde7f0',
'--workspace-accent-border': '#9fb2c5',
'--workspace-accent-contrast': '#f7fafc',
'--status-active': '#4e6c61',
'--status-cooling': '#94724a',
'--status-abandoned': '#93636c',
'--card-bg': '#fafcfd',
},
dark: {
'--ink': '#d8dee5',
'--paper': '#161c21',
'--warm-gray': '#252d35',
'--muted': '#5d6771',
'--accent-amber': '#a08a6a',
'--accent-sage': '#6a8a7a',
'--accent-slate': '#6a8a9a',
'--accent-rose': '#9a7a7a',
'--workspace-accent': '#6a8a9a',
'--workspace-accent-soft': '#252d35',
'--workspace-accent-border': '#3a4a5a',
'--workspace-accent-contrast': '#f8f5f0',
'--status-active': '#5a9a7a',
'--status-cooling': '#c8a83a',
'--status-abandoned': '#c36a6a',
'--card-bg': '#1c232b',
},
},
blush: {
name: 'Blush',
meta: 'Soft clay',
light: {
'--ink': '#201716',
'--paper': '#f6efec',
'--warm-gray': '#e5d8d2',
'--muted': '#97827c',
'--accent-amber': '#a06d4f',
'--accent-sage': '#6a7866',
'--accent-slate': '#64707a',
'--accent-rose': '#ad6966',
'--workspace-accent': '#a5656f',
'--workspace-accent-soft': '#f2dfe1',
'--workspace-accent-border': '#d2a1a7',
'--workspace-accent-contrast': '#fff7f8',
'--status-active': '#5a7162',
'--status-cooling': '#9c7448',
'--status-abandoned': '#a96262',
'--card-bg': '#fffaf7',
},
dark: {
'--ink': '#e8e2da',
'--paper': '#201716',
'--warm-gray': '#332a2a',
'--muted': '#8a7a7a',
'--accent-amber': '#a06d4f',
'--accent-sage': '#6a7a6a',
'--accent-slate': '#6a7a8a',
'--accent-rose': '#c37a7a',
'--workspace-accent': '#a5656f',
'--workspace-accent-soft': '#332a2a',
'--workspace-accent-border': '#5a4a4a',
'--workspace-accent-contrast': '#f6efec',
'--status-active': '#5a9a7a',
'--status-cooling': '#b89a4a',
'--status-abandoned': '#c36a6a',
'--card-bg': '#281f1f',
},
},
};
let themePreferences = {
mode: 'system',
paletteId: 'paper',
customBackground: '',
surfaceOpacity: 14,
uiScale: 100,
shortcutScale: 100,
hitokotoEnabled: true,
sleepControlEnabled: false,
closeDuplicateNewTabsEnabled: false,
savedSessionRestoreMode: 'new-window',
savedSessionNavDisplayMode: 'name',
};
let systemThemeMediaQuery = null;
let systemThemeListener = null;
function normalizeThemePreferences(input) {
const next = input && typeof input === 'object' ? input : {};
const legacyThemeId = String(next.themeId || '');
const migrated = LEGACY_THEME_MIGRATION[legacyThemeId] || null;
const rawMode = String(next.mode || migrated?.mode || 'system');
const rawPaletteId = String(next.paletteId || migrated?.paletteId || 'paper');
const rawOpacity = Number(next.surfaceOpacity);
const surfaceOpacity = Number.isFinite(rawOpacity)
? Math.min(60, Math.max(2, Math.round(rawOpacity)))
: 14;
const rawUiScale = Number(next.uiScale);
const uiScale = Number.isFinite(rawUiScale)
? Math.min(120, Math.max(100, Math.round(rawUiScale)))
: 100;
const rawShortcutScale = Number(next.shortcutScale);
const shortcutScale = Number.isFinite(rawShortcutScale)
? Math.min(130, Math.max(100, Math.round(rawShortcutScale)))
: 100;
const rawSavedSessionRestoreMode = String(next.savedSessionRestoreMode || 'new-window');
const rawSavedSessionNavDisplayMode = String(next.savedSessionNavDisplayMode || 'name');
return {
mode: VALID_THEME_MODES.has(rawMode) ? rawMode : 'system',
paletteId: VALID_THEME_PALETTES.has(rawPaletteId) ? rawPaletteId : 'paper',
customBackground: typeof next.customBackground === 'string' ? next.customBackground : '',
surfaceOpacity,
uiScale,
shortcutScale,
hitokotoEnabled: next.hitokotoEnabled !== false,
sleepControlEnabled: next.sleepControlEnabled === true,
closeDuplicateNewTabsEnabled: next.closeDuplicateNewTabsEnabled === true,
savedSessionRestoreMode: VALID_SAVED_SESSION_RESTORE_MODES.has(rawSavedSessionRestoreMode) ? rawSavedSessionRestoreMode : 'new-window',
savedSessionNavDisplayMode: VALID_SAVED_SESSION_NAV_DISPLAY_MODES.has(rawSavedSessionNavDisplayMode) ? rawSavedSessionNavDisplayMode : 'name',
};
}
function getSavedSessionRestoreMode(preferences = themePreferences) {
return normalizeThemePreferences(preferences).savedSessionRestoreMode;
}
function getSavedSessionNavDisplayMode(preferences = themePreferences) {
return normalizeThemePreferences(preferences).savedSessionNavDisplayMode;
}
function normalizeQuickShortcuts(input) {
if (!Array.isArray(input)) return [];
return input
.filter(item => item && item.url)
.map(item => {
const normalizedIcon = normalizeShortcutIcon(item.icon || item.customIcon || '');
return {
id: String(item.id || `shortcut-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`),
url: String(item.url).trim(),
label: String(item.label || '').trim(),
icon: normalizedIcon.value,
iconKind: normalizedIcon.kind,
};
})
.filter(item => item.url);
}
function isSvgMarkup(value) {
const text = String(value || '').trim();
return /^<svg[\s>]/i.test(text) || /^<\?xml[\s\S]*<svg[\s>]/i.test(text);
}
function svgToDataUrl(svgText) {
const normalized = String(svgText || '').trim();
if (!normalized) return '';
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(normalized)}`;
}
function extractIconFromClipboardHtml(html) {
const raw = String(html || '').trim();
if (!raw) return { value: '', kind: '' };
if (isSvgMarkup(raw)) {
return { value: raw, kind: 'svg' };
}
const imageMatch = raw.match(/<img[^>]+src=["']([^"']+)["']/i);
if (imageMatch?.[1]) {
const src = imageMatch[1].trim();
if (/^data:image\//i.test(src)) {
return { value: src, kind: 'image' };
}
}
const svgMatch = raw.match(/<svg[\s\S]*<\/svg>/i);
if (svgMatch?.[0]) {
return { value: svgMatch[0], kind: 'svg' };
}
return { value: '', kind: '' };
}
function isTransientClipboardReference(value) {
const text = String(value || '').trim();
if (!text) return false;
return (
/^file:\/\//i.test(text) ||
/^blob:/i.test(text) ||
/^\/(private|var|tmp|Users)\//.test(text) ||
/^[A-Za-z]:\\/.test(text)
);
}
function getSystemThemeMode() {
return Boolean(window.matchMedia?.('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
}
function getResolvedTone(preferences = themePreferences) {
const normalized = normalizeThemePreferences(preferences);
if (normalized.mode === 'system') {
return getSystemThemeMode();
}
return normalized.mode;
}
function getThemeFamilyDefinition(paletteId) {
return THEME_FAMILIES[paletteId] || THEME_FAMILIES.paper;
}
function getResolvedThemeDefinition(preferences = themePreferences) {
const normalized = normalizeThemePreferences(preferences);
const resolvedTone = getResolvedTone(normalized);
const family = getThemeFamilyDefinition(normalized.paletteId);
return {
id: normalized.paletteId,
name: family.name,
meta: family.meta,
tone: resolvedTone,
vars: family[resolvedTone],
};
}
function getPalettePreviewStyle(paletteId) {
const family = getThemeFamilyDefinition(paletteId);
return `--theme-paper:${family.light['--paper']};--theme-accent:${family.light['--accent-amber']};`;
}
function syncSystemThemeSubscription() {
if (systemThemeMediaQuery && systemThemeListener) {
if (typeof systemThemeMediaQuery.removeEventListener === 'function') {
systemThemeMediaQuery.removeEventListener('change', systemThemeListener);
} else if (typeof systemThemeMediaQuery.removeListener === 'function') {
systemThemeMediaQuery.removeListener(systemThemeListener);
}
}
systemThemeMediaQuery = window.matchMedia?.('(prefers-color-scheme: dark)') || null;
systemThemeListener = null;
if (!systemThemeMediaQuery) return;
systemThemeListener = () => {
if (themePreferences.mode !== 'system') return;
applyThemePreferences();
renderThemeMenu();
};
if (typeof systemThemeMediaQuery.addEventListener === 'function') {
systemThemeMediaQuery.addEventListener('change', systemThemeListener);
} else if (typeof systemThemeMediaQuery.addListener === 'function') {
systemThemeMediaQuery.addListener(systemThemeListener);
}
}
function prefersReducedMotion() {
return Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches);
}
function focusFirstElement(container) {
if (!container) return null;
const target = container.querySelector(FOCUSABLE_SELECTOR);
target?.focus?.({ preventScroll: true });
return target || null;
}
function setThemeMenuOpen(nextOpen, { restoreFocus = false } = {}) {
themeMenuOpen = Boolean(nextOpen);
renderThemeMenu();
if (themeMenuOpen) {
const panel = document.getElementById('themeMenuPanel');
requestAnimationFrame(() => {
focusFirstElement(panel);
});
return;
}
if (restoreFocus) {
document.getElementById('themeMenuTrigger')?.focus?.({ preventScroll: true });
}
}
function hexToRgbChannels(hex) {
const value = String(hex || '').replace('#', '');
if (value.length !== 6) return '248 245 240';
const r = parseInt(value.slice(0, 2), 16);
const g = parseInt(value.slice(2, 4), 16);
const b = parseInt(value.slice(4, 6), 16);
return `${r} ${g} ${b}`;
}
function computeThemeOpacityVars(surfaceOpacity) {
return {
'--custom-surface-opacity': `${surfaceOpacity}%`,
'--custom-border-opacity': `${Math.max(8, surfaceOpacity)}%`,
'--custom-badge-opacity': `${Math.max(3, Math.round(surfaceOpacity * 0.28))}%`,
'--custom-fallback-opacity': `${Math.max(4, Math.round(surfaceOpacity * 0.36))}%`,
};
}
function computeThemeSizeVars({ uiScale = 100, shortcutScale = 100 } = {}) {
const normalizedUiScale = Math.min(120, Math.max(100, Math.round(Number(uiScale) || 100)));
const normalizedShortcutScale = Math.min(130, Math.max(100, Math.round(Number(shortcutScale) || 100)));
const shortcutRatio = normalizedShortcutScale / 100;
const scaledFont = basePx => `${Math.round(basePx * normalizedUiScale) / 100}px`;
return {
'--ui-scale': String(normalizedUiScale / 100),
'--shortcut-scale': String(shortcutRatio),
'--ui-font-8': scaledFont(8),
'--ui-font-9': scaledFont(9),
'--ui-font-10': scaledFont(10),
'--ui-font-11': scaledFont(11),
'--ui-font-12': scaledFont(12),
'--ui-font-13': scaledFont(13),
'--ui-font-14': scaledFont(14),
'--ui-font-15': scaledFont(15),
'--ui-font-16': scaledFont(16),
'--ui-font-17': scaledFont(17),
'--ui-font-18': scaledFont(18),
'--ui-font-20': scaledFont(20),
'--ui-font-22': scaledFont(22),
'--ui-font-24': scaledFont(24),
'--ui-font-28': scaledFont(28),
'--ui-font-40': scaledFont(40),
'--quick-shortcut-card-size': `${Math.round(76 * shortcutRatio)}px`,
'--quick-shortcut-shell-size': `${Math.round(40 * shortcutRatio)}px`,
'--quick-shortcut-icon-wrap-size': `${Math.round(40 * shortcutRatio)}px`,
'--quick-shortcut-icon-size': `${Math.round(22 * shortcutRatio)}px`,
'--quick-shortcut-label-size': `${Math.round(11 * shortcutRatio)}px`,
};
}
function applyThemePreferences() {
const root = document.documentElement;
const body = document.body;
const theme = getResolvedThemeDefinition(themePreferences);
const opacityVars = computeThemeOpacityVars(themePreferences.surfaceOpacity);
const sizeVars = computeThemeSizeVars(themePreferences);
Object.entries(theme.vars).forEach(([name, value]) => {
root.style.setProperty(name, value);
});
Object.entries(opacityVars).forEach(([name, value]) => {
root.style.setProperty(name, value);
});
Object.entries(sizeVars).forEach(([name, value]) => {
root.style.setProperty(name, value);
});
if (body) {
body.classList.toggle('theme-tone-light', theme.tone === 'light');
body.classList.toggle('theme-tone-dark', theme.tone === 'dark');
}
if (themePreferences.customBackground) {
root.style.setProperty('--page-custom-background', `url("${themePreferences.customBackground}")`);
if (body) {
const paperRgb = hexToRgbChannels(theme.vars['--paper']);
body.style.backgroundImage = `linear-gradient(rgba(${paperRgb} / 0.26), rgba(${paperRgb} / 0.26)), url("${themePreferences.customBackground}")`;
body.classList.add('has-custom-background');
}
} else {
root.style.setProperty('--page-custom-background', 'none');
if (body) {
body.style.removeProperty('background-image');
body.classList.remove('has-custom-background');
}
}
}
function renderThemeMenu() {
const trigger = document.getElementById('themeMenuTrigger');
const modeOptions = document.getElementById('themeModeOptions');
const panel = document.getElementById('themeMenuPanel');
const options = document.getElementById('themeOptions');
const transparencyRange = document.getElementById('themeTransparencyRange');
const transparencyValue = document.getElementById('themeTransparencyValue');
const uiScaleRange = document.getElementById('themeUiScaleRange');
const uiScaleValue = document.getElementById('themeUiScaleValue');
const shortcutScaleRange = document.getElementById('themeShortcutScaleRange');
const shortcutScaleValue = document.getElementById('themeShortcutScaleValue');
if (
!trigger ||
!panel ||
!modeOptions ||
!options ||
!transparencyRange ||
!transparencyValue ||
!uiScaleRange ||
!uiScaleValue ||
!shortcutScaleRange ||
!shortcutScaleValue
) return;
trigger.setAttribute('aria-expanded', String(themeMenuOpen));
panel.hidden = !themeMenuOpen;
const activeThemeMenuTab = themeMenuActiveTab === 'features' ? 'features' : 'appearance';
transparencyRange.value = String(themePreferences.surfaceOpacity);
transparencyValue.textContent = `${themePreferences.surfaceOpacity}%`;
uiScaleRange.value = String(themePreferences.uiScale);
uiScaleValue.textContent = `${themePreferences.uiScale}%`;
shortcutScaleRange.value = String(themePreferences.shortcutScale);
shortcutScaleValue.textContent = `${themePreferences.shortcutScale}%`;
modeOptions.innerHTML = THEME_MODE_ORDER.map(id => `
<button
class="theme-mode-option ${themePreferences.mode === id ? 'is-active' : ''}"
type="button"
data-action="select-theme-mode"
data-theme-mode="${id}"
aria-pressed="${themePreferences.mode === id}"
>${themeT ? themeT(THEME_MODE_LABEL_KEYS[id]) : id}</button>
`).join('');
options.innerHTML = THEME_PALETTE_ORDER.map(id => {
const family = getThemeFamilyDefinition(id);
return `
<button
class="theme-option ${themePreferences.paletteId === id ? 'is-active' : ''}"
type="button"
data-action="select-theme"
data-palette-id="${id}"
aria-pressed="${themePreferences.paletteId === id}"
style="${getPalettePreviewStyle(id)}"
>
<span class="theme-option-main">
<span class="theme-option-swatch" aria-hidden="true"></span>
<span>
<span class="theme-option-name">${themeEscapeHtml ? themeEscapeHtml(family.name) : family.name}</span>
</span>
</span>
<span class="theme-option-check" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="m5 13 4 4L19 7" /></svg>
</span>
</button>
`;
}).join('');
panel.querySelectorAll('[data-theme-menu-panel]').forEach(item => {
const isActive = item.dataset.themeMenuPanel === activeThemeMenuTab;
item.hidden = !isActive;
});
panel.querySelectorAll('[data-theme-menu-tab]').forEach(item => {
const isActive = item.dataset.themeMenuTab === activeThemeMenuTab;
item.classList.toggle('is-active', isActive);
item.setAttribute('aria-selected', String(isActive));
});
}
async function getQuickShortcuts() {
const stored = await chrome.storage.local.get(QUICK_SHORTCUTS_KEY);
return normalizeQuickShortcuts(stored[QUICK_SHORTCUTS_KEY]);
}
async function saveQuickShortcuts(shortcuts) {
const normalized = normalizeQuickShortcuts(shortcuts);
await chrome.storage.local.set({ [QUICK_SHORTCUTS_KEY]: normalized });
return normalized;
}
async function removeQuickShortcutById(shortcutId) {
if (!shortcutId) return await getQuickShortcuts();
const shortcuts = await getQuickShortcuts();
return await saveQuickShortcuts(shortcuts.filter(item => item.id !== shortcutId));
}
async function saveQuickShortcutOrder(orderIds) {
const shortcuts = await getQuickShortcuts();
if (!Array.isArray(orderIds) || !orderIds.length || !themeReorderSubsetByIds) {
return shortcuts;
}
return await saveQuickShortcuts(themeReorderSubsetByIds(shortcuts, orderIds));
}
function normalizeShortcutUrl(input) {
const raw = String(input || '').trim();
if (!raw) return '';
const withProtocol = /^[a-z]+:\/\//i.test(raw) ? raw : `https://${raw}`;
try {
return new URL(withProtocol).toString();
} catch {
return '';
}
}
function normalizeShortcutIcon(input) {
const raw = String(input || '').trim();
if (!raw) return { value: '', kind: '' };
if (isSvgMarkup(raw)) {
return { value: raw, kind: 'svg' };
}
if (/^data:image\//i.test(raw)) {
return { value: raw, kind: 'image' };
}
if (/^[a-z]+:\/\//i.test(raw) || raw.includes('.') || raw.startsWith('/')) {
const normalizedUrl = normalizeShortcutUrl(raw);
if (normalizedUrl) {
return { value: normalizedUrl, kind: 'image' };
}
}
const glyph = [...raw].slice(0, 2).join('');
return { value: glyph, kind: glyph ? 'glyph' : '' };
}
function createShortcutEditorState(input = {}) {
return {
open: Boolean(input.open),
mode: input.mode === 'edit' ? 'edit' : 'create',
shortcutId: String(input.shortcutId || ''),
url: String(input.url || ''),
label: String(input.label || ''),
icon: String(input.icon || ''),
iconKind: String(input.iconKind || 'site'),
presentation: input.presentation === 'tab-picker' ? 'tab-picker' : 'default',
returnToTabPicker: Boolean(input.returnToTabPicker),
focusReturnEl: input.focusReturnEl instanceof HTMLElement ? input.focusReturnEl : null,
};
}
function createTabPickerManualDraft(input = {}) {
return {
url: String(input.url || ''),
label: String(input.label || ''),
};
}
function getShortcutEditorElements() {
return {
modalBackdrop: document.getElementById('shortcutEditorBackdrop'),
modalPanel: document.getElementById('shortcutEditor'),
embeddedHost: document.getElementById('tabPickerEditorHost'),
form: document.getElementById('shortcutEditorForm'),
title: document.getElementById('shortcutEditorTitle'),
back: document.getElementById('shortcutEditorBack'),
url: document.getElementById('shortcutEditorUrl'),
label: document.getElementById('shortcutEditorLabel'),
source: document.getElementById('shortcutEditorSource'),
sourceButtons: [...document.querySelectorAll('[data-action="select-shortcut-source"]')],
emoji: document.getElementById('shortcutEditorEmoji'),
svgCode: document.getElementById('shortcutEditorSvgCode'),
siteGroup: document.getElementById('shortcutEditorSiteGroup'),
emojiGroup: document.getElementById('shortcutEditorEmojiGroup'),
imageGroup: document.getElementById('shortcutEditorImageGroup'),
svgGroup: document.getElementById('shortcutEditorSvgGroup'),
preview: document.getElementById('shortcutEditorPreview'),
previewFallback: document.getElementById('shortcutEditorPreviewFallback'),
previewTitle: document.getElementById('shortcutEditorPreviewTitle'),
previewMeta: document.getElementById('shortcutEditorPreviewMeta'),
fileInput: document.getElementById('shortcutIconFileInput'),
};
}
function getTabPickerElements() {
return {
panel: document.getElementById('tabPicker'),
tabsTab: document.getElementById('tabPickerTabsTab'),
urlTab: document.getElementById('tabPickerUrlTab'),
searchWrap: document.getElementById('tabPickerSearchWrap'),
search: document.getElementById('tabPickerSearch'),
list: document.getElementById('tabPickerList'),
editorHost: document.getElementById('tabPickerEditorHost'),
footer: document.getElementById('tabPickerFooter'),
};
}
function syncFormControlValue(element, nextValue) {
if (!element) return;
const normalized = String(nextValue || '');
const isActive = element === document.activeElement;
if (element.dataset.composing === 'true') return;
if (isActive && element.value === normalized) return;
if (element.value !== normalized) {
element.value = normalized;
}
}
function resetShortcutEditorPosition(panel) {
if (!panel) return;
panel.style.removeProperty('left');
panel.style.removeProperty('top');
panel.style.removeProperty('right');
panel.style.removeProperty('bottom');
panel.style.removeProperty('inset');
}
function positionShortcutEditor(triggerEl = null) {
if (shortcutEditorState.presentation === 'tab-picker') return;
const panel = document.getElementById('shortcutEditor');
if (!panel) return;
if (!(triggerEl instanceof HTMLElement)) {
resetShortcutEditorPosition(panel);
return;
}
const viewportPadding = 16;
const offset = 14;
const triggerRect = triggerEl.getBoundingClientRect();
const panelRect = panel.getBoundingClientRect();
const panelWidth = panelRect.width || 360;
const panelHeight = panelRect.height || 420;
const fitsBelow = triggerRect.bottom + offset + panelHeight <= window.innerHeight - viewportPadding;
const top = fitsBelow
? triggerRect.bottom + offset
: Math.max(viewportPadding, triggerRect.top - panelHeight - offset);
const left = Math.min(
Math.max(triggerRect.left - 8, viewportPadding),
window.innerWidth - panelWidth - viewportPadding
);
panel.style.inset = 'auto';
panel.style.left = `${Math.round(left)}px`;
panel.style.top = `${Math.round(top)}px`;
}
function restoreShortcutEditorHome() {
const elements = getShortcutEditorElements();
if (!elements.form || !elements.modalPanel) return;
if (elements.form.parentElement !== elements.modalPanel) {
elements.modalPanel.appendChild(elements.form);
}
}
function mountShortcutEditorInTabPicker() {
const elements = getShortcutEditorElements();
if (!elements.form || !elements.embeddedHost) return;
if (elements.form.parentElement !== elements.embeddedHost) {
elements.embeddedHost.appendChild(elements.form);
}
}
function syncShortcutEditor() {
const elements = getShortcutEditorElements();
if (!elements.form) return;
const isEmbedded = shortcutEditorState.presentation === 'tab-picker';
if (isEmbedded) {
mountShortcutEditorInTabPicker();
if (elements.modalPanel) elements.modalPanel.hidden = true;
if (elements.modalBackdrop) elements.modalBackdrop.hidden = true;
if (elements.embeddedHost) elements.embeddedHost.hidden = !shortcutEditorState.open;
elements.form.classList.add('is-tab-picker-pane');
} else {
restoreShortcutEditorHome();
if (elements.modalPanel) elements.modalPanel.hidden = !shortcutEditorState.open;
if (elements.modalBackdrop) elements.modalBackdrop.hidden = !shortcutEditorState.open;
if (elements.embeddedHost) elements.embeddedHost.hidden = true;
elements.form.classList.remove('is-tab-picker-pane');
}
if (elements.back) {
elements.back.hidden = true;
}
if (shortcutEditorState.presentation === 'tab-picker' && shortcutEditorState.mode === 'create') {
elements.title.textContent = themeT ? themeT('addByUrlTitle') : 'Add by URL';
} else {
elements.title.textContent = shortcutEditorState.mode === 'edit'
? (themeT ? themeT('shortcutEditTitle') : 'Edit shortcut')
: (themeT ? themeT('shortcutAddTitle') : 'Add shortcut');
}
syncFormControlValue(elements.url, shortcutEditorState.url);
syncFormControlValue(elements.label, shortcutEditorState.label);
elements.sourceButtons.forEach(button => {
const isSelected = button.dataset.source === shortcutEditorState.iconKind;
button.setAttribute('aria-pressed', String(isSelected));
});
syncFormControlValue(elements.emoji, shortcutEditorState.iconKind === 'glyph' ? shortcutEditorState.icon : '');
if (elements.svgCode) {
syncFormControlValue(elements.svgCode, shortcutEditorState.iconKind === 'svg' ? shortcutEditorState.icon : '');
}
if (elements.siteGroup) elements.siteGroup.hidden = shortcutEditorState.iconKind !== 'site';
if (elements.emojiGroup) elements.emojiGroup.hidden = shortcutEditorState.iconKind !== 'glyph';
if (elements.imageGroup) elements.imageGroup.hidden = shortcutEditorState.iconKind !== 'image';
if (elements.svgGroup) elements.svgGroup.hidden = shortcutEditorState.iconKind !== 'svg';
const label = shortcutEditorState.label.trim() || shortcutEditorState.url.trim() || (themeT ? themeT('shortcutPreviewFallbackLabel') : 'Shortcut');
const previewTitle = shortcutEditorState.iconKind === 'image'
? (themeT ? themeT('shortcutPreviewCustomImageIcon') : 'Custom image icon')
: shortcutEditorState.iconKind === 'svg'
? (themeT ? themeT('shortcutPreviewSvgIcon') : 'SVG icon')
: shortcutEditorState.iconKind === 'glyph'
? (themeT ? themeT('shortcutPreviewEmojiIcon') : 'Emoji icon')
: (themeT ? themeT('shortcutPreviewWebsiteIcon') : 'Website icon');
const previewMeta = shortcutEditorState.iconKind
? (themeT ? themeT('shortcutPreviewHasCustomIcon') : 'Custom icon will replace the site favicon.')
: (themeT ? themeT('shortcutPreviewNoCustomIcon') : 'Upload or paste an image, or type an emoji.');
if (elements.previewTitle) elements.previewTitle.textContent = previewTitle;
if (elements.previewMeta) elements.previewMeta.textContent = previewMeta;
if (elements.preview) {
elements.preview.setAttribute('aria-label', `${previewTitle}. ${previewMeta}`);
}
if (elements.preview) {
elements.preview.innerHTML = '';
if ((shortcutEditorState.iconKind === 'image' || shortcutEditorState.iconKind === 'svg') && shortcutEditorState.icon) {
const img = document.createElement('img');
img.src = shortcutEditorState.iconKind === 'svg' ? svgToDataUrl(shortcutEditorState.icon) : shortcutEditorState.icon;
img.alt = '';
img.addEventListener('error', () => {
img.remove();
if (elements.previewFallback) {
elements.previewFallback.textContent = themeGetFallbackLabel(label, '');
elements.preview.appendChild(elements.previewFallback);
}
});
elements.preview.appendChild(img);
} else if (shortcutEditorState.iconKind === 'glyph' && shortcutEditorState.icon) {
const glyph = document.createElement('span');
glyph.className = 'shortcut-editor-preview-glyph';
glyph.setAttribute('aria-hidden', 'true');
glyph.textContent = shortcutEditorState.icon;
elements.preview.appendChild(glyph);
} else if (elements.previewFallback) {
elements.previewFallback.textContent = themeGetFallbackLabel(label, '');
elements.preview.appendChild(elements.previewFallback);
}
}
}
function openShortcutEditor(shortcut = null, triggerEl = null, options = {}) {
const normalized = shortcut ? normalizeQuickShortcuts([shortcut])[0] : null;
if (options.presentation === 'tab-picker') {
mountShortcutEditorInTabPicker();
} else {
restoreShortcutEditorHome();
}
shortcutEditorState = createShortcutEditorState({
open: true,
mode: normalized ? 'edit' : 'create',
shortcutId: normalized?.id || '',
url: normalized?.url || '',
label: normalized?.label || '',
icon: normalized?.icon || '',
iconKind: normalized?.iconKind || 'site',
presentation: options.presentation,
returnToTabPicker: options.returnToTabPicker,
focusReturnEl: triggerEl instanceof HTMLElement ? triggerEl : document.activeElement,
});
syncShortcutEditor();
requestAnimationFrame(() => {
if (shortcutEditorState.presentation !== 'tab-picker') {
positionShortcutEditor(triggerEl);
}
getShortcutEditorElements().url?.focus?.({ preventScroll: true });
});
}
function closeShortcutEditor({ restoreFocus = true } = {}) {
const focusTarget = shortcutEditorState.focusReturnEl;
const wasEmbedded = shortcutEditorState.presentation === 'tab-picker';
resetShortcutEditorPosition(getShortcutEditorElements().modalPanel);
shortcutEditorState = createShortcutEditorState();
syncShortcutEditor();
restoreShortcutEditorHome();
if (wasEmbedded) {
closeTabPicker({ restoreFocus });
return;
}
if (restoreFocus) {
focusTarget?.focus?.({ preventScroll: true });
}
}
function setShortcutEditorField(field, value) {
shortcutEditorState = createShortcutEditorState({
...shortcutEditorState,
[field]: value,
});
syncShortcutEditor();
}
function syncTabPickerLayout() {
const elements = getTabPickerElements();
if (!elements.panel) return;
const isUrlMode = tabPickerMode === 'url';
if (elements.tabsTab) {
elements.tabsTab.classList.toggle('is-active', !isUrlMode);
elements.tabsTab.setAttribute('aria-selected', String(!isUrlMode));
}
if (elements.urlTab) {
elements.urlTab.classList.toggle('is-active', isUrlMode);
elements.urlTab.setAttribute('aria-selected', String(isUrlMode));
}
if (elements.searchWrap) {
elements.searchWrap.hidden = isUrlMode;
}
if (elements.list) {
elements.list.hidden = isUrlMode;
}
if (elements.editorHost) {
elements.editorHost.hidden = !isUrlMode;
}
if (elements.footer && isUrlMode) {
elements.footer.hidden = true;
}
}
function setTabPickerMode(nextMode, { focus = true } = {}) {
tabPickerMode = nextMode === 'url' ? 'url' : 'tabs';
if (tabPickerMode === 'url') {
openShortcutEditor(null, tabPickerFocusReturnEl || document.activeElement, {
presentation: 'tab-picker',
});
} else if (shortcutEditorState.presentation === 'tab-picker') {
shortcutEditorState = createShortcutEditorState();
syncShortcutEditor();
restoreShortcutEditorHome();
}
syncTabPickerLayout();
requestAnimationFrame(() => {
if (!focus) return;
const elements = getTabPickerElements();
if (tabPickerMode === 'url') {
getShortcutEditorElements().url?.focus?.({ preventScroll: true });
return;
}
elements.search?.focus?.({ preventScroll: true });
});
if (tabPickerMode === 'tabs') {
renderTabPickerPanel();
}
}
function setTabPickerManualField(field, value) {
tabPickerManualDraft = createTabPickerManualDraft({
...tabPickerManualDraft,
[field]: value,
});
}
function setShortcutEditorIcon(input) {
const normalized = normalizeShortcutIcon(input);
shortcutEditorState = createShortcutEditorState({
...shortcutEditorState,
icon: normalized.value,
iconKind: normalized.kind || 'site',
});
syncShortcutEditor();
}
function setShortcutEditorSource(source) {
const nextSource = ['site', 'glyph', 'image', 'svg'].includes(source) ? source : 'site';
shortcutEditorState = createShortcutEditorState({
...shortcutEditorState,
iconKind: nextSource,
icon: nextSource === shortcutEditorState.iconKind ? shortcutEditorState.icon : '',
});
syncShortcutEditor();
}
async function applyShortcutEditorImageFile(file) {
if (!themeCompressImageFileForStorage) {
throw new Error(themeT ? themeT('errorImageCompressionUnavailable') : 'Image compression is unavailable');
}
const dataUrl = await themeCompressImageFileForStorage(file, {
maxBytes: 96 * 1024,
maxEdge: 160,
});
setShortcutEditorIcon(dataUrl);
}
async function saveShortcutEditorShortcut() {
const url = normalizeShortcutUrl(shortcutEditorState.url);
if (!url) {
throw new Error(themeT ? themeT('errorPleaseEnterValidUrl') : 'Please enter a valid URL');
}
const shortcuts = await getQuickShortcuts();
const nextShortcut = {
id: shortcutEditorState.shortcutId || `shortcut-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
url,
label: shortcutEditorState.label.trim(),
icon: shortcutEditorState.icon,
iconKind: shortcutEditorState.iconKind,
};
const nextShortcuts = shortcutEditorState.mode === 'edit'
? shortcuts.map(item => item.id === nextShortcut.id ? nextShortcut : item)
: [...shortcuts, nextShortcut];
await saveQuickShortcuts(nextShortcuts);
await renderQuickShortcuts();
closeShortcutEditor();
}
async function saveTabPickerUrlShortcut() {
const url = normalizeShortcutUrl(tabPickerManualDraft.url);
if (!url) {
throw new Error('Please enter a valid URL');
}
const shortcuts = await getQuickShortcuts();
if (shortcuts.some(item => item.url === url)) {
throw new Error('Already in shortcuts');
}
const nextShortcut = {
id: `shortcut-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
url,
label: tabPickerManualDraft.label.trim(),
icon: '',
iconKind: 'site',
};
await saveQuickShortcuts([...shortcuts, nextShortcut]);
await renderQuickShortcuts();
closeTabPicker();
}
function getShortcutLabel(shortcut) {
if (shortcut.label) return shortcut.label;
try {
return friendlyDomain(new URL(shortcut.url).hostname);
} catch {
return shortcut.url;
}
}
function animateQuickShortcutNode(item, previousRect) {
if (!item || !previousRect) return;
const nextRect = item.getBoundingClientRect();
const deltaX = previousRect.left - nextRect.left;
const deltaY = previousRect.top - nextRect.top;
if (!deltaX && !deltaY) return;
const travel = Math.hypot(deltaX, deltaY);
const duration = prefersReducedMotion()
? 0
: Math.min(380, Math.max(240, Math.round(228 + travel * 0.4)));
item.style.transition = 'none';
item.style.transform = `translate3d(${deltaX}px, ${deltaY}px, 0)`;
requestAnimationFrame(() => {
item.style.transition = duration
? `transform ${duration}ms cubic-bezier(0.22, 1, 0.36, 1)`
: 'none';
item.style.transform = '';
});
}
function animateQuickShortcutItems(listEl, previousRects, affectedIds = null) {
const affected = affectedIds instanceof Set ? affectedIds : null;
listEl?.querySelectorAll('[data-shortcut-id]:not(.is-drag-slot)').forEach(item => {
const key = item.dataset.shortcutId || '';
if (affected && !affected.has(key)) return;
animateQuickShortcutNode(item, previousRects.get(key));
});
}
function settleQuickShortcutItems(listEl, affectedIds = null) {
const affected = affectedIds instanceof Set ? affectedIds : null;
listEl?.querySelectorAll('[data-shortcut-id]:not(.is-drag-slot)').forEach(item => {
const key = item.dataset.shortcutId || '';
if (affected && !affected.has(key)) return;
item.style.transition = 'none';
item.style.transform = '';
});
}
function ensureQuickShortcutSlot() {
if (quickShortcutSlotEl || !quickShortcutDraggedEl) return quickShortcutSlotEl;
quickShortcutSlotEl = document.createElement('div');
quickShortcutSlotEl.className = 'quick-shortcut-slot is-drag-slot';
quickShortcutSlotEl.dataset.shortcutId = quickShortcutDraggedId;
quickShortcutSlotEl.style.width = `${quickShortcutDragState?.width || quickShortcutDraggedEl.getBoundingClientRect().width}px`;
quickShortcutSlotEl.style.height = `${quickShortcutDragState?.height || quickShortcutDraggedEl.getBoundingClientRect().height}px`;
quickShortcutDraggedEl.replaceWith(quickShortcutSlotEl);
return quickShortcutSlotEl;
}
function ensureQuickShortcutGhost() {
if (quickShortcutGhostEl || !quickShortcutDraggedEl) return quickShortcutGhostEl;
quickShortcutGhostEl = quickShortcutDraggedEl.cloneNode(true);
quickShortcutGhostEl.classList.remove('is-drag-origin');
quickShortcutGhostEl.classList.add('is-drag-ghost');
quickShortcutGhostEl.style.setProperty('--drag-width', `${quickShortcutDragState?.width || quickShortcutDraggedEl.getBoundingClientRect().width}px`);
quickShortcutGhostEl.style.setProperty('--drag-height', `${quickShortcutDragState?.height || quickShortcutDraggedEl.getBoundingClientRect().height}px`);
document.body.appendChild(quickShortcutGhostEl);
return quickShortcutGhostEl;
}
function clearQuickShortcutDragState() {
quickShortcutDragState = null;
quickShortcutDraggedId = '';
document.body.classList.remove('quick-shortcut-list-dragging');
quickShortcutDraggedEl = null;
quickShortcutGhostEl?.remove();
quickShortcutGhostEl = null;
quickShortcutSlotEl?.remove();
quickShortcutSlotEl = null;
}
function clampQuickShortcutDragPoint(clientX, clientY) {
const listEl = quickShortcutDragState?.listEl;
if (!listEl || !quickShortcutDragState) {
return { clientX, clientY };
}
const listRect = listEl.getBoundingClientRect();
const width = Number(quickShortcutDragState.width) || 0;
const height = Number(quickShortcutDragState.height) || 0;
const minClientX = listRect.left + quickShortcutDragState.offsetX - width / 2;
const maxClientX = listRect.right + quickShortcutDragState.offsetX - width / 2;
const minClientY = listRect.top + quickShortcutDragState.offsetY - height / 2;
const maxClientY = listRect.bottom + quickShortcutDragState.offsetY - height / 2;
return {
clientX: Math.min(Math.max(clientX, minClientX), maxClientX),
clientY: Math.min(Math.max(clientY, minClientY), maxClientY),
};
}
function updateDraggedQuickShortcutPosition(clientX, clientY) {
if (!quickShortcutGhostEl || !quickShortcutDragState) return;
quickShortcutGhostEl.style.setProperty('--drag-left', `${clientX - quickShortcutDragState.offsetX}px`);
quickShortcutGhostEl.style.setProperty('--drag-top', `${clientY - quickShortcutDragState.offsetY}px`);
}
function buildQuickShortcutSlotTargets(listEl) {
if (!(listEl instanceof HTMLElement)) return [];
return [...listEl.querySelectorAll('[data-shortcut-id]')].map(item => {
const rect = item.getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
centerX: rect.left + rect.width / 2,
centerY: rect.top + rect.height / 2,
};
});
}
function findQuickShortcutSlotIndex(slotTargets, draggedCenterX, draggedCenterY) {
if (!Array.isArray(slotTargets) || !slotTargets.length) return -1;
let targetIndex = 0;
let closestDistance = Number.POSITIVE_INFINITY;
slotTargets.forEach((slot, index) => {
const dx = draggedCenterX - slot.centerX;
const dy = draggedCenterY - slot.centerY;
const distance = (dx * dx) + (dy * dy);
if (distance < closestDistance) {
closestDistance = distance;
targetIndex = index;
}
});
return targetIndex;
}
function previewQuickShortcutOrder(clientX, clientY) {
const listEl = quickShortcutDragState?.listEl;
if (!listEl || !quickShortcutDraggedId || !quickShortcutSlotEl) return;
const clampedPoint = clampQuickShortcutDragPoint(clientX, clientY);
const draggedCenterX = clampedPoint.clientX - quickShortcutDragState.offsetX + quickShortcutDragState.width / 2;
const draggedCenterY = clampedPoint.clientY - quickShortcutDragState.offsetY + quickShortcutDragState.height / 2;
const items = [...listEl.querySelectorAll('[data-shortcut-id]:not(.is-drag-slot)')];
if (!items.length) return;
const targetIndex = findQuickShortcutSlotIndex(
quickShortcutDragState.slotTargets,
draggedCenterX,
draggedCenterY
);
if (targetIndex === -1) return;
const insertBeforeItem = items[targetIndex] || null;
const addCard = listEl.querySelector('.quick-shortcut-card.is-add');
const targetBeforeNode = insertBeforeItem || addCard || null;
const currentBeforeNode = quickShortcutSlotEl.nextElementSibling || null;
if (targetBeforeNode === currentBeforeNode) return;
const previousOrderIds = [...listEl.querySelectorAll('[data-shortcut-id]')]
.map(item => item.dataset.shortcutId || '')
.filter(Boolean);
const previousSlotIndex = previousOrderIds.indexOf(quickShortcutDraggedId);
const previousRects = new Map();
listEl.querySelectorAll('[data-shortcut-id]:not(.is-drag-slot)').forEach(item => {
previousRects.set(item.dataset.shortcutId || '', item.getBoundingClientRect());
});
const previousSlotRect = quickShortcutSlotEl.getBoundingClientRect();
if (insertBeforeItem) {
listEl.insertBefore(quickShortcutSlotEl, insertBeforeItem);
} else {
if (addCard) {
listEl.insertBefore(quickShortcutSlotEl, addCard);
} else {
listEl.appendChild(quickShortcutSlotEl);
}
}
const nextOrderIds = [...listEl.querySelectorAll('[data-shortcut-id]')]
.map(item => item.dataset.shortcutId || '')
.filter(Boolean);
const nextSlotIndex = nextOrderIds.indexOf(quickShortcutDraggedId);
const affectedIds = new Set(
nextOrderIds.filter(id => previousOrderIds.indexOf(id) !== nextOrderIds.indexOf(id))
);
const rangeStart = Math.min(previousSlotIndex, nextSlotIndex);
const rangeEnd = Math.max(previousSlotIndex, nextSlotIndex);
nextOrderIds.forEach((id, index) => {
if (index >= rangeStart && index <= rangeEnd) {
affectedIds.add(id);
}
});
settleQuickShortcutItems(listEl, affectedIds);
animateQuickShortcutItems(listEl, previousRects, affectedIds);
animateQuickShortcutNode(quickShortcutSlotEl, previousSlotRect);
}
function renderQuickShortcutCard(shortcut) {
const label = getShortcutLabel(shortcut);
const safeLabel = themeEscapeHtml ? themeEscapeHtml(label) : label;
const safeAriaLabel = themeEscapeHtmlAttribute ? themeEscapeHtmlAttribute(label) : label.replace(/"/g, '&quot;');
const iconData = themeGetIconSources ? themeGetIconSources({ url: shortcut.url }, 32) : { sources: [], hostname: '' };
const faviconUrl = iconData.sources[0] || '';
const fallbackLabel = themeGetFallbackLabel(label, iconData.hostname);
const safeId = themeEscapeHtmlAttribute ? themeEscapeHtmlAttribute(shortcut.id) : shortcut.id.replace(/"/g, '&quot;');
const safeUrl = themeEscapeHtmlAttribute ? themeEscapeHtmlAttribute(shortcut.url) : shortcut.url.replace(/"/g, '&quot;');
const customIcon = normalizeShortcutIcon(shortcut.icon);
const primaryIconUrl = customIcon.kind === 'image'
? customIcon.value
: customIcon.kind === 'svg'
? svgToDataUrl(customIcon.value)
: customIcon.kind === 'glyph'
? ''
: faviconUrl;
const glyphIcon = customIcon.kind === 'glyph' ? customIcon.value : '';
const fallbackSources = (customIcon.kind === 'image' || customIcon.kind === 'svg')
? iconData.sources
: iconData.sources.slice(1);
const iconErrorFallback = fallbackSources[0] || '';
const safeIconErrorFallback = themeEscapeHtmlAttribute ? themeEscapeHtmlAttribute(iconErrorFallback) : iconErrorFallback.replace(/"/g, '&quot;');
const fallbackSrcset = fallbackSources.length > 1 ? JSON.stringify(fallbackSources.slice(1)) : '';
const safeFallbackSrcset = fallbackSrcset
? (themeEscapeHtmlAttribute ? themeEscapeHtmlAttribute(fallbackSrcset) : fallbackSrcset.replace(/"/g, '&quot;'))
: '';
return `
<div class="quick-shortcut-card" data-shortcut-id="${safeId}">
<button class="quick-shortcut-open" type="button" data-action="open-quick-shortcut" data-shortcut-url="${safeUrl}" aria-label="${safeAriaLabel}" draggable="false">
<span class="quick-shortcut-icon-wrap">
${primaryIconUrl ? `<img class="quick-shortcut-icon${customIcon.kind === 'image' ? ' quick-shortcut-icon-custom' : ''}" src="${primaryIconUrl}" alt="" draggable="false" data-fallback-src="${safeIconErrorFallback}"${safeFallbackSrcset ? ` data-fallback-srcset="${safeFallbackSrcset}"` : ''}>` : ''}
${glyphIcon ? `<span class="quick-shortcut-custom-glyph" aria-hidden="true">${glyphIcon}</span>` : ''}
<span class="quick-shortcut-fallback"${primaryIconUrl || glyphIcon ? ' style="display:none"' : ''}>${fallbackLabel}</span>
</span>
<span class="quick-shortcut-label">${safeLabel}</span>
</button>
<button class="quick-shortcut-edit" type="button" data-action="edit-quick-shortcut" data-shortcut-id="${safeId}" aria-label="${themeT ? themeT('editQuickTab') : 'Edit quick tab'}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m16.862 4.487 1.687-1.688a2.25 2.25 0 1 1 3.182 3.182L10.582 17.13a4.5 4.5 0 0 1-1.897 1.13L6 19l.74-2.685a4.5 4.5 0 0 1 1.13-1.897L16.862 4.487ZM19.5 7.125 16.875 4.5" /></svg>
</button>
<button class="quick-shortcut-remove" type="button" data-action="remove-quick-shortcut" data-shortcut-id="${safeId}" aria-label="${themeT ? themeT('removeQuickTab') : 'Remove quick tab'}">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
</button>
</div>
`;
}
function renderQuickShortcutAddCard() {
return `
<div class="quick-shortcut-card is-add">
<button class="quick-shortcut-open" type="button" data-action="add-quick-shortcut" aria-label="${themeT ? themeT('addQuickTab') : 'Add quick tab'}">
<span class="quick-shortcut-icon-wrap">
<svg class="quick-shortcut-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.8" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 5.25v13.5m6.75-6.75H5.25" />
</svg>
</span>
<span class="quick-shortcut-label">${themeT ? themeT('addLink') : 'Add link'}</span>
</button>
</div>
`;
}
async function renderQuickShortcuts() {
const list = document.getElementById('quickTabsList');
if (!list) return;
const shortcuts = await getQuickShortcuts();
list.innerHTML = `${shortcuts.map(renderQuickShortcutCard).join('')}${renderQuickShortcutAddCard()}`;
}
async function openShortcutEditorById(shortcutId, triggerEl = null) {
const shortcuts = await getQuickShortcuts();
const shortcut = shortcuts.find(item => item.id === shortcutId);
if (!shortcut) return;
openShortcutEditor(shortcut, triggerEl);
}
async function handleShortcutEditorPaste() {
if (!shortcutEditorState.open) {
return;
}
if (!navigator.clipboard?.read) {
showToast(themeT ? themeT('toastClipboardUsePasteShortcut') : 'Press Cmd/Ctrl+V inside the editor to paste an image or SVG');
return;
}
try {
const items = await navigator.clipboard.read();
for (const item of items) {
const imageType = item.types.find(type => type.startsWith('image/'));
if (!imageType) continue;
const blob = await item.getType(imageType);
const file = new File([blob], 'shortcut-icon.png', { type: imageType });
await applyShortcutEditorImageFile(file);
showToast(themeT ? themeT('toastShortcutIconPasted') : 'Shortcut icon pasted');
return;
}
const textItem = items.find(item => item.types.includes('text/plain'));
const htmlItem = items.find(item => item.types.includes('text/html'));
if (htmlItem) {
const htmlBlob = await htmlItem.getType('text/html');
const html = await htmlBlob.text();
const normalized = extractIconFromClipboardHtml(html);
if (normalized.kind) {
setShortcutEditorIcon(normalized.value);
showToast(normalized.kind === 'svg'
? (themeT ? themeT('toastSvgIconPasted') : 'SVG icon pasted')
: (themeT ? themeT('toastShortcutIconPasted') : 'Shortcut icon pasted'));
return;
}
const htmlImageMatch = html.match(/<img[^>]+src=["']([^"']+)["']/i);
if (htmlImageMatch?.[1] && isTransientClipboardReference(htmlImageMatch[1])) {
showToast(themeT ? themeT('toastClipboardTemporaryRef') : 'This clipboard image is a temporary file reference. Use Cmd/Ctrl+V instead.');
return;
}
}
if (textItem) {
const textBlob = await textItem.getType('text/plain');
const text = await textBlob.text();
const normalized = normalizeShortcutIcon(text);
if (normalized.kind === 'svg' || /^data:image\//i.test(String(normalized.value || ''))) {
setShortcutEditorIcon(normalized.value);
showToast(normalized.kind === 'svg'
? (themeT ? themeT('toastSvgIconPasted') : 'SVG icon pasted')
: (themeT ? themeT('toastShortcutIconPasted') : 'Shortcut icon pasted'));
return;
}
if (isTransientClipboardReference(text)) {
showToast(themeT ? themeT('toastClipboardTemporaryRef') : 'This clipboard image is a temporary file reference. Use Cmd/Ctrl+V instead.');
return;
}
}
showToast(themeT ? themeT('toastClipboardNoImage') : 'Clipboard does not contain an image or SVG');
} catch (err) {
showToast(themeT ? themeT('toastClipboardUsePasteShortcut') : 'Use Cmd/Ctrl+V inside the editor if direct clipboard access is unavailable');
}
}
async function tryShortcutEditorPasteViaExecCommand() {
return new Promise(resolve => {
const target = document.createElement('textarea');
target.setAttribute('aria-hidden', 'true');
target.style.position = 'fixed';
target.style.opacity = '0';
target.style.pointerEvents = 'none';
target.style.inset = '0 auto auto -9999px';
document.body.appendChild(target);
let finished = false;
const cleanup = (result) => {
if (finished) return;
finished = true;
target.removeEventListener('paste', onPaste);
target.remove();
resolve(result);
};
const onPaste = async (e) => {
const imageItem = [...(e.clipboardData?.items || [])].find(item => item.type.startsWith('image/'));
if (imageItem) {
const file = imageItem.getAsFile();
if (file) {
e.preventDefault();
try {
await applyShortcutEditorImageFile(file);
showToast(themeT ? themeT('toastShortcutIconPasted') : 'Shortcut icon pasted');
cleanup(true);
return;
} catch {
cleanup(false);
return;
}
}
}
const pastedHtml = e.clipboardData?.getData('text/html') || '';
const normalizedFromHtml = extractIconFromClipboardHtml(pastedHtml);
if (normalizedFromHtml.kind) {
e.preventDefault();
setShortcutEditorIcon(normalizedFromHtml.value);
showToast(normalizedFromHtml.kind === 'svg'
? (themeT ? themeT('toastSvgIconPasted') : 'SVG icon pasted')
: (themeT ? themeT('toastShortcutIconPasted') : 'Shortcut icon pasted'));
cleanup(true);
return;
}
cleanup(false);
};
target.addEventListener('paste', onPaste, { once: true });
target.focus({ preventScroll: true });
let commandWorked = false;
try {
commandWorked = document.execCommand('paste');
} catch { }
setTimeout(() => cleanup(commandWorked), 120);
});
}
// ---- Tab Picker ----
function filterRealTabs(tabs) {
return tabs.filter(t => {
const url = t.url || '';
return (
!url.startsWith('chrome://') &&
!url.startsWith('chrome-extension://') &&
!url.startsWith('about:') &&
!url.startsWith('edge://') &&
!url.startsWith('brave://')
);
});
}
function openTabPicker(triggerEl = null) {
if (tabPickerOpen) return;
tabPickerOpen = true;
tabPickerMode = 'tabs';
tabPickerSearchQuery = '';
tabPickerSelectedIds = new Set();
tabPickerFocusReturnEl = document.activeElement;
tabPickerManualDraft = createTabPickerManualDraft();
const backdrop = document.getElementById('tabPickerBackdrop');
const panel = document.getElementById('tabPicker');
if (backdrop) backdrop.removeAttribute('hidden');
if (panel) panel.removeAttribute('hidden');
shortcutEditorState = createShortcutEditorState();
syncShortcutEditor();
restoreShortcutEditorHome();
syncTabPickerLayout();
renderTabPickerPanel();
getTabPickerElements().search?.focus?.({ preventScroll: true });
}
function closeTabPicker({ restoreFocus = true } = {}) {
if (!tabPickerOpen) return;
if (shortcutEditorState.presentation === 'tab-picker') {
shortcutEditorState = createShortcutEditorState();
syncShortcutEditor();
restoreShortcutEditorHome();
}
tabPickerOpen = false;
tabPickerMode = 'tabs';
tabPickerSearchQuery = '';
tabPickerSelectedIds = new Set();
tabPickerManualDraft = createTabPickerManualDraft();
const backdrop = document.getElementById('tabPickerBackdrop');
const panel = document.getElementById('tabPicker');
if (backdrop) backdrop.setAttribute('hidden', '');
if (panel) panel.setAttribute('hidden', '');
if (restoreFocus && tabPickerFocusReturnEl) {
tabPickerFocusReturnEl.focus();
tabPickerFocusReturnEl = null;
}
}
async function renderTabPickerPanel() {
if (!tabPickerOpen) return;
if (tabPickerMode !== 'tabs') {
syncTabPickerLayout();
return;
}
const runtime = globalThis.TabHarborDashboardRuntime;
if (!runtime) return;
await runtime.fetchOpenTabs();
const allTabs = runtime.getOpenTabs();
const realTabs = filterRealTabs(allTabs);
const shortcuts = await getQuickShortcuts();
const existingUrls = new Set(shortcuts.map(s => s.url));
const query = tabPickerSearchQuery.trim().toLowerCase();
const filtered = query
? realTabs.filter(t => {
const title = (t.title || '').toLowerCase();
const url = (t.url || '').toLowerCase();
return title.includes(query) || url.includes(query);
})
: realTabs;
const byDomain = new Map();
for (const tab of filtered) {
let hostname = '';
try { hostname = new URL(tab.url).hostname; } catch { }
const group = hostname.replace(/^www\./, '') || 'other';
if (!byDomain.has(group)) byDomain.set(group, []);
byDomain.get(group).push(tab);
}
const listEl = document.getElementById('tabPickerList');
if (!listEl) return;
if (filtered.length === 0) {
listEl.innerHTML = `<div class="tab-picker-empty">${query ? 'No tabs match your search.' : 'No open tabs found.'}</div>`;
syncTabPickerFooter();
return;
}
let html = '';
for (const [domain, tabs] of byDomain) {
html += `<div class="tab-picker-group-label">${friendlyDomain(domain) || domain}</div>`;
for (const tab of tabs) {
const tabId = String(tab.id);
const isSelected = tabPickerSelectedIds.has(tabId);
const isAdded = existingUrls.has(tab.url);
const title = stripTitleNoise(tab.title) || tab.url;
const safeTitle = themeEscapeHtmlAttribute(title);
const fallbackInitial = (friendlyDomain(tab.url ? new URL(tab.url).hostname : '') || '?')[0] || '?';
let faviconHtml;
if (tab.favIconUrl) {
faviconHtml = `<img class="tab-picker-favicon" src="${themeEscapeHtmlAttribute(tab.favIconUrl)}" alt="" data-fallback-src=""><span class="tab-picker-favicon-fallback" style="display:none">${fallbackInitial.toUpperCase()}</span>`;
} else {
faviconHtml = `<span class="tab-picker-favicon-fallback">${fallbackInitial.toUpperCase()}</span>`;
}
const checkbox = `<input class="tab-picker-checkbox" type="checkbox" ${isSelected ? 'checked' : ''} data-action="toggle-tab-picker-selection" data-tab-id="${tabId}" aria-label="Select ${safeTitle}">`;
let actionIcon;
if (isAdded) {
actionIcon = `<svg class="tab-picker-added-icon" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" /></svg>`;
} else {
actionIcon = `<button class="tab-picker-add-btn" type="button" data-action="add-tab-to-shortcuts" data-tab-id="${tabId}" aria-label="Add ${safeTitle} to shortcuts">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" /></svg>
</button>`;
}
html += `<div class="tab-picker-row ${isSelected ? 'is-selected' : ''}" role="option" aria-selected="${isSelected}">
${checkbox}
${faviconHtml}
<span class="tab-picker-tab-title" title="${safeTitle}">${safeTitle}</span>
${actionIcon}
</div>`;
}
}
listEl.innerHTML = html;
syncTabPickerFooter();
}
function syncTabPickerFooter() {
const footer = document.getElementById('tabPickerFooter');
const count = document.getElementById('tabPickerFooterCount');
if (!footer || !count) return;
const count_val = tabPickerSelectedIds.size;
if (count_val === 0) {
footer.setAttribute('hidden', '');
} else {
footer.removeAttribute('hidden');
count.textContent = `${count_val} selected`;
}
}
async function addSingleTabToQuickShortcuts(tab) {
const shortcuts = await getQuickShortcuts();
if (shortcuts.some(s => s.url === tab.url)) {
showToast('Already in shortcuts');
return;
}
const hostname = tab.url ? (() => { try { return new URL(tab.url).hostname; } catch { return ''; } })() : '';
const label = stripTitleNoise(tab.title) || friendlyDomain(hostname) || hostname || tab.url;
const nextShortcut = {
id: `shortcut-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
url: tab.url,
label,
icon: tab.favIconUrl || '',
iconKind: tab.favIconUrl ? 'image' : 'site',
};
const updated = [...shortcuts, nextShortcut];
await saveQuickShortcuts(updated);
await renderQuickShortcuts();
showToast('Tab added — undo?', {
action: {
label: 'Undo',
fn: async () => {
await removeQuickShortcutById(nextShortcut.id);
await renderQuickShortcuts();
},
},
});
}
async function addSelectedTabsToQuickShortcuts() {
const runtime = globalThis.TabHarborDashboardRuntime;
if (!runtime) return;
const allTabs = runtime.getOpenTabs();
const selectedTabs = allTabs.filter(t => tabPickerSelectedIds.has(String(t.id)));
if (selectedTabs.length === 0) return;
const shortcuts = await getQuickShortcuts();
const existingUrls = new Set(shortcuts.map(s => s.url));
const newShortcuts = [];
for (const tab of selectedTabs) {
const shortcutUrl = tab.url || '';
if (existingUrls.has(shortcutUrl)) continue;
const hostname = shortcutUrl ? (() => { try { return new URL(shortcutUrl).hostname; } catch { return ''; } })() : '';
const label = stripTitleNoise(tab.title) || friendlyDomain(hostname) || hostname || shortcutUrl;
newShortcuts.push({
id: `shortcut-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
url: shortcutUrl,
label,
icon: tab.favIconUrl || '',
iconKind: tab.favIconUrl ? 'image' : 'site',
});
existingUrls.add(shortcutUrl);
}
await saveQuickShortcuts([...shortcuts, ...newShortcuts]);
await renderQuickShortcuts();
closeTabPicker();
showToast(`${newShortcuts.length} tab${newShortcuts.length !== 1 ? 's' : ''} added`);
}
// ---- Escape / backdrop click handlers for picker ----
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && tabPickerOpen) {
closeTabPicker();
}
});
document.addEventListener('click', (e) => {
if (!tabPickerOpen) return;
if (e.target.id === 'tabPickerBackdrop') {
closeTabPicker();
}
});
// ---- Tab picker search ----
document.addEventListener('input', (e) => {
if (e.target.id !== 'tabPickerSearch') return;
tabPickerSearchQuery = e.target.value || '';
renderTabPickerPanel();
});
// ---- Main action click handler ----
document.addEventListener('click', async (e) => {
const actionEl = e.target.closest('[data-action]');
if (!actionEl) return;
const action = actionEl.dataset.action;
if (action === 'add-quick-shortcut') {
e.preventDefault();
e.stopImmediatePropagation();
openTabPicker(actionEl);
return;
}
if (action === 'switch-tab-picker-view') {
e.preventDefault();
setTabPickerMode(actionEl.dataset.view || 'tabs');
return;
}
if (action === 'show-tab-picker-tabs') {
e.preventDefault();
setTabPickerMode('tabs');
return;
}
if (action === 'open-tab-picker') {
e.preventDefault();
e.stopImmediatePropagation();
openTabPicker(actionEl);
return;
}
if (action === 'close-tab-picker') {
closeTabPicker();
return;
}
if (action === 'add-tab-to-shortcuts') {
const tabId = actionEl.dataset.tabId;
if (!tabId) return;
const runtime = globalThis.TabHarborDashboardRuntime;
if (!runtime) return;
const tab = runtime.getOpenTabs().find(t => String(t.id) === tabId);
if (!tab) return;
await addSingleTabToQuickShortcuts(tab);
await renderTabPickerPanel();
return;
}
if (action === 'toggle-tab-picker-selection') {
const row = actionEl.closest('.tab-picker-row');
const tabId = actionEl.dataset.tabId || '';
if (tabPickerSelectedIds.has(tabId)) {
tabPickerSelectedIds.delete(tabId);
if (row) row.classList.remove('is-selected');
} else {
tabPickerSelectedIds.add(tabId);
if (row) row.classList.add('is-selected');
}
syncTabPickerFooter();
return;
}
if (action === 'add-selected-tabs') {
e.preventDefault();
await addSelectedTabsToQuickShortcuts();
return;
}
if (action === 'clear-tab-picker-selection') {
tabPickerSelectedIds = new Set();
renderTabPickerPanel();
return;
}
if (action === 'edit-quick-shortcut') {
e.preventDefault();
e.stopImmediatePropagation();
const shortcutId = actionEl.dataset.shortcutId || '';
await openShortcutEditorById(shortcutId, actionEl);
return;
}
if (action === 'remove-quick-shortcut') {
e.stopImmediatePropagation();
const shortcutId = actionEl.dataset.shortcutId;
if (!shortcutId) return;
await removeQuickShortcutById(shortcutId);
await renderQuickShortcuts();
showToast(themeT ? themeT('toastQuickTabRemoved') : 'Quick tab removed');
return;
}
if (action === 'open-quick-shortcut') {
e.stopImmediatePropagation();
if (Date.now() < quickShortcutSuppressClickUntil) return;
const url = actionEl.dataset.shortcutUrl;
if (!url) return;
await openOrFocusUrl(url);
return;
}
if (action === 'close-shortcut-editor') {
e.preventDefault();
closeShortcutEditor({ restoreFocus: true });
return;
}
if (action === 'select-shortcut-source') {
e.preventDefault();
setShortcutEditorSource(actionEl.dataset.source || 'site');
if (actionEl.dataset.source === 'glyph') {
getShortcutEditorElements().emoji?.focus?.({ preventScroll: true });
}
return;
}
if (action === 'upload-shortcut-icon') {
e.preventDefault();
getShortcutEditorElements().fileInput?.click();
return;
}
if (action === 'paste-shortcut-icon') {
e.preventDefault();
const pasted = await tryShortcutEditorPasteViaExecCommand();
if (!pasted) {
await handleShortcutEditorPaste();
}
return;
}
if (action === 'clear-shortcut-icon') {
e.preventDefault();
setShortcutEditorIcon('');
return;
}
});
document.addEventListener('pointerdown', (e) => {
const shortcutButton = e.target.closest('.quick-shortcut-open');
if (!shortcutButton || e.button !== 0) return;
if (shortcutButton.dataset.action !== 'open-quick-shortcut') return;
const item = shortcutButton.closest('[data-shortcut-id]');
const listEl = item?.parentElement;
if (!item || !listEl) return;
quickShortcutDraggedId = item.dataset.shortcutId || '';
quickShortcutDraggedEl = item;
const rect = item.getBoundingClientRect();
quickShortcutDragState = {
listEl,
x: e.clientX,
y: e.clientY,
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
slotTargets: buildQuickShortcutSlotTargets(listEl),
width: rect.width,
height: rect.height,
moved: false,
};
});
document.addEventListener('pointermove', (e) => {
if (!quickShortcutDraggedId || !quickShortcutDragState) return;
const distance = Math.hypot(e.clientX - quickShortcutDragState.x, e.clientY - quickShortcutDragState.y);
if (!quickShortcutDragState.moved && distance < 4) return;
if (!quickShortcutDragState.moved) {
quickShortcutDragState.moved = true;
document.body.classList.add('quick-shortcut-list-dragging');
ensureQuickShortcutSlot();
ensureQuickShortcutGhost();
}
updateDraggedQuickShortcutPosition(e.clientX, e.clientY);
previewQuickShortcutOrder(e.clientX, e.clientY);
});
document.addEventListener('pointerup', async () => {
if (!quickShortcutDraggedId || !quickShortcutDragState) return;
const moved = quickShortcutDragState.moved;
const nextOrderIds = moved
? [...quickShortcutDragState.listEl.children]
.map(node => {
if (node === quickShortcutSlotEl) return quickShortcutDraggedId;
return node.dataset?.shortcutId || '';
})
.filter(Boolean)
: [];
clearQuickShortcutDragState();
if (!moved) return;
quickShortcutSuppressClickUntil = Date.now() + 250;
await saveQuickShortcutOrder(nextOrderIds);
await renderQuickShortcuts();
});
document.addEventListener('input', (e) => {
if (e.target.id === 'shortcutEditorUrl') {
setShortcutEditorField('url', e.target.value);
return;
}
if (e.target.id === 'shortcutEditorLabel') {
setShortcutEditorField('label', e.target.value);
return;
}
if (e.target.id === 'shortcutEditorEmoji') {
const normalized = normalizeShortcutIcon(e.target.value);
shortcutEditorState = createShortcutEditorState({
...shortcutEditorState,
icon: normalized.value,
iconKind: normalized.kind || 'glyph',
});
syncShortcutEditor();
return;
}
if (e.target.id === 'shortcutEditorSvgCode') {
const normalized = normalizeShortcutIcon(e.target.value);
shortcutEditorState = createShortcutEditorState({
...shortcutEditorState,
icon: normalized.value,
iconKind: normalized.kind || 'svg',
});
syncShortcutEditor();
}
});
document.addEventListener('compositionstart', (e) => {
if (!(e.target instanceof HTMLElement)) return;
if (!['shortcutEditorUrl', 'shortcutEditorLabel', 'shortcutEditorEmoji', 'shortcutEditorSvgCode'].includes(e.target.id)) {
return;
}
e.target.dataset.composing = 'true';
});
document.addEventListener('compositionend', (e) => {
if (!(e.target instanceof HTMLElement)) return;
if (e.target.dataset.composing !== 'true') return;
delete e.target.dataset.composing;
if (e.target.id === 'shortcutEditorUrl') {
setShortcutEditorField('url', e.target.value);
return;
}
if (e.target.id === 'shortcutEditorLabel') {
setShortcutEditorField('label', e.target.value);
return;
}
});
document.addEventListener('change', async (e) => {
if (e.target.id !== 'shortcutIconFileInput') return;
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
try {
await applyShortcutEditorImageFile(file);
showToast(themeT ? themeT('toastShortcutIconUpdated') : 'Shortcut icon updated');
} catch (err) {
showToast(err?.message || (themeT ? themeT('toastCouldNotUseShortcutImage') : 'Could not use shortcut image'));
}
});
document.addEventListener('paste', async (e) => {
if (!shortcutEditorState.open) return;
const imageItem = [...(e.clipboardData?.items || [])].find(item => item.type.startsWith('image/'));
if (imageItem) {
const file = imageItem.getAsFile();
if (!file) return;
e.preventDefault();
try {
await applyShortcutEditorImageFile(file);
showToast(themeT ? themeT('toastShortcutIconPasted') : 'Shortcut icon pasted');
} catch (err) {
showToast(err?.message || (themeT ? themeT('toastCouldNotPasteShortcutImage') : 'Could not paste shortcut image'));
}
return;
}
const pastedHtml = e.clipboardData?.getData('text/html') || '';
const normalizedFromHtml = extractIconFromClipboardHtml(pastedHtml);
if (normalizedFromHtml.kind) {
e.preventDefault();
setShortcutEditorIcon(normalizedFromHtml.value);
showToast(normalizedFromHtml.kind === 'svg'
? (themeT ? themeT('toastSvgIconPasted') : 'SVG icon pasted')
: (themeT ? themeT('toastShortcutIconPasted') : 'Shortcut icon pasted'));
return;
}
const pastedText = e.clipboardData?.getData('text/plain') || '';
const normalized = normalizeShortcutIcon(pastedText);
if (normalized.kind === 'svg') {
e.preventDefault();
setShortcutEditorIcon(normalized.value);
showToast(themeT ? themeT('toastSvgIconPasted') : 'SVG icon pasted');
}
});
document.addEventListener('submit', async (e) => {
if (e.target.id !== 'shortcutEditorForm') return;
e.preventDefault();
try {
const mode = shortcutEditorState.mode;
await saveShortcutEditorShortcut();
showToast(mode === 'edit'
? (themeT ? themeT('toastQuickTabUpdated') : 'Quick tab updated')
: (themeT ? themeT('toastQuickTabAdded') : 'Quick tab added'));
} catch (err) {
showToast(err?.message || (themeT ? themeT('toastCouldNotSaveShortcut') : 'Could not save shortcut'));
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && shortcutEditorState.open) {
closeShortcutEditor({ restoreFocus: true });
}
});
document.addEventListener('click', (e) => {
if (!shortcutEditorState.open) return;
if (e.target.id === 'shortcutEditorBackdrop') {
closeShortcutEditor({ restoreFocus: true });
}
});
async function loadThemePreferences() {
const stored = await chrome.storage.local.get(THEME_PREFERENCES_KEY);
themePreferences = normalizeThemePreferences(stored[THEME_PREFERENCES_KEY]);
syncSystemThemeSubscription();
applyThemePreferences();
renderThemeMenu();
return themePreferences;
}
function syncPopupTheme(targetDoc) {
const root = targetDoc?.documentElement;
const body = targetDoc?.body;
if (!root) return;
const theme = getResolvedThemeDefinition(themePreferences);
const opacityVars = computeThemeOpacityVars(themePreferences.surfaceOpacity);
const sizeVars = computeThemeSizeVars(themePreferences);
Object.entries(theme.vars).forEach(([name, value]) => {
root.style.setProperty(name, value);
});
Object.entries(opacityVars).forEach(([name, value]) => {
root.style.setProperty(name, value);
});
Object.entries(sizeVars).forEach(([name, value]) => {
root.style.setProperty(name, value);
});
if (body) {
body.classList.toggle('theme-tone-light', theme.tone === 'light');
body.classList.toggle('theme-tone-dark', theme.tone === 'dark');
}
}
async function saveThemePreferences(nextPreferences) {
themePreferences = normalizeThemePreferences({
...themePreferences,
...nextPreferences,
});
await chrome.storage.local.set({ [THEME_PREFERENCES_KEY]: themePreferences });
syncSystemThemeSubscription();
applyThemePreferences();
renderThemeMenu();
return themePreferences;
}
async function saveSavedSessionRestoreMode(mode) {
return saveThemePreferences({ savedSessionRestoreMode: mode });
}
async function saveSavedSessionNavDisplayMode(mode) {
return saveThemePreferences({ savedSessionNavDisplayMode: mode });
}
globalThis.TabOutThemeControls = {
filterRealTabs,
getSavedSessionNavDisplayMode,
getSavedSessionRestoreMode,
getResolvedThemeDefinition,
getResolvedTone,
getQuickShortcuts,
loadThemePreferences,
normalizeShortcutUrl,
normalizeQuickShortcuts,
normalizeThemePreferences,
removeQuickShortcutById,
saveSavedSessionNavDisplayMode,
saveSavedSessionRestoreMode,
saveQuickShortcutOrder,
saveQuickShortcuts,
syncPopupTheme,
};