shell: the shell frame, createShell(site) (moved from wayBetter for X)
packages/shell/src/frame (waybetter/shell/frame): the whole shell of an app. An app describes its site (ShellSite: names and pages, account rules, search, address chips and completions, its panel tab, the new-tab page, palette items, deck column kinds and flags and an optional compose dock, the media panel's composer rule, a theme store, overlays, the native features it serves) and passes its Tauri API and i18n instance; createShell returns the root component. - Shell.tsx: dockview host (tabs in the titlebar row, splits, drag-link drops, shortcuts, context menu, deck back-and-forth, session restore, workspace on start, overlay raise); state.ts (pane placeholders, tabs_sync one-in-flight via syncGate, shell_raise layer, profiles, layout). - WebPanel (address bar), NewTabPage, CommandPalette + paletteModel (frame groups; site items), DeckView + AddColumnDialog + deckStore (site column kinds), PaneTheme + themeStore (createThemeStore over the app's ThemeAdapter) + chromeTheme (ChromeTheme input) + themeScopes, Titlebar + TitlebarExtras + icons + brand (wB mark data URI), AccountSwitcher + accountModel, workspaces + workspaceModel, backupModel, syncHost, devHost + devModel, pluginCommandStore, MediaPanel + mediaModel, searchInput, hooks, bridge (installPreviewMocks), i18n, boot.ts (bootScript: the first-frame boot.js), CSS themed with --wb-* tokens. - paneModel: paneVisibility, raiseSupported, cssRgb; deckModel: isSourcePage. - Includes the live-QA titlebar fix (dock ResizeObserver + rAF titlebar marks, dock sized by its insets). - tests/frame.test.ts: every frame key has English and is used, palette order with a site, address fallback, layout gate, boot script, chrome variables, switcher rules, workspaces, deck settings with site flags. - npm package 0.4.0: exports ./shell/frame, ./shell/frame/*, ./shell/frame/*.css (copied by the build); dockview and acorn optional peers; root devDependencies dockview, acorn.
This commit is contained in:
parent
892de6cbf4
commit
068168469c
49 changed files with 6442 additions and 6 deletions
116
packages/shell/src/frame/CommandPalette.tsx
Normal file
116
packages/shell/src/frame/CommandPalette.tsx
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useT } from './hooks.ts';
|
||||
import { t } from './i18n.ts';
|
||||
import { Avatar, Icon } from './icons.tsx';
|
||||
import { buildItems, moveSelection, type PaletteAction, type PaletteContext, type PaletteItem } from './paletteModel.ts';
|
||||
import { hasFeature, site } from './site.ts';
|
||||
import { setOverlay } from './state.ts';
|
||||
|
||||
/** Leading visual for a palette row: the tab's favicon, the account avatar or the item's glyph. */
|
||||
function leading(item: PaletteItem, context: PaletteContext): ReactNode {
|
||||
const [kind, ...rest] = item.id.split(':');
|
||||
const key = rest.join(':');
|
||||
if (kind === 'tab') {
|
||||
const favicon = context.tabs.find(tab => `tab:${tab.id}` === item.id)?.favicon;
|
||||
return favicon ? <img src={favicon} alt="" width={16} height={16}/> : <Icon name="globe"/>;
|
||||
}
|
||||
if (kind === 'profile') {
|
||||
const profile = context.profiles.find(p => p.id === key);
|
||||
return <Avatar name={profile?.name ?? '?'} color={profile?.color ?? '#8a90a6'} size={20}/>;
|
||||
}
|
||||
if (item.icon) return <Icon name={item.icon}/>;
|
||||
if (kind === 'deck' || kind === 'deck-new') return <Icon name="grid"/>;
|
||||
if (kind === 'deck-add') return <Icon name="plus"/>;
|
||||
if (kind === 'deck-focus') return <Icon name="list"/>;
|
||||
if (kind === 'ws') return <Icon name="grid"/>;
|
||||
if (kind === 'ws-save') return <Icon name="plus"/>;
|
||||
return <Icon name="search"/>;
|
||||
}
|
||||
|
||||
const shortHint = (hint: string) => hint.replace(/^https?:\/\/(www\.)?/, '');
|
||||
|
||||
/** The frame's group names are translated; the site names its own groups; plugin groups are the plugins' names. */
|
||||
const GROUPS: Record<string, string> = {
|
||||
Tabs: 'wb.palette.group.tabs', Deck: 'wb.palette.group.deck', Workspaces: 'wb.palette.group.workspaces', Profiles: 'wb.palette.group.profiles',
|
||||
};
|
||||
const groupLabel = (group: string) => (GROUPS[group] ? t(GROUPS[group]!) : site().palette.groupLabel?.(group) ?? group);
|
||||
|
||||
/** Ctrl+K palette. The shell rises above the tab webviews (or hides them) while it is open. */
|
||||
export function CommandPalette({ context, onRun, onClose }: { context: PaletteContext; onRun: (action: PaletteAction) => void; onClose: () => void }) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [selected, setSelected] = useState(0);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const list = useRef<HTMLDivElement>(null);
|
||||
const items = useMemo(() => {
|
||||
const { palette, product, deck, media } = site();
|
||||
return buildItems(context, query, { site: palette, product, decks: !!deck, media: !!media, workspaces: hasFeature('workspaces') });
|
||||
}, [context, query]);
|
||||
|
||||
useEffect(() => {
|
||||
setOverlay(1);
|
||||
input.current?.focus();
|
||||
const close = () => onClose();
|
||||
window.addEventListener('blur', close);
|
||||
return () => { setOverlay(-1); window.removeEventListener('blur', close); };
|
||||
}, []);
|
||||
useEffect(() => setSelected(0), [query]);
|
||||
useEffect(() => { list.current?.querySelector('[aria-selected="true"]')?.scrollIntoView({ block: 'nearest' }); }, [selected]);
|
||||
|
||||
const run = (index: number) => {
|
||||
const item = items[index];
|
||||
if (!item) return;
|
||||
if (item.action.type === 'complete') { setQuery(item.action.text); input.current?.focus(); return; }
|
||||
onClose();
|
||||
onRun(item.action);
|
||||
};
|
||||
const onKeyDown = (event: ReactKeyboardEvent) => {
|
||||
if (event.nativeEvent.isComposing) return;
|
||||
// Ctrl+K again closes, like the shortcut that opened it.
|
||||
if ((event.ctrlKey || event.metaKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'k') { event.preventDefault(); event.stopPropagation(); onClose(); return; }
|
||||
if (event.key === 'ArrowDown' || (event.key === 'Tab' && !event.shiftKey)) { event.preventDefault(); setSelected(i => moveSelection(i, 1, items.length)); }
|
||||
else if (event.key === 'ArrowUp' || (event.key === 'Tab' && event.shiftKey)) { event.preventDefault(); setSelected(i => moveSelection(i, -1, items.length)); }
|
||||
else if (event.key === 'Home' && event.ctrlKey) { event.preventDefault(); setSelected(0); }
|
||||
else if (event.key === 'End' && event.ctrlKey) { event.preventDefault(); setSelected(Math.max(0, items.length - 1)); }
|
||||
else if (event.key === 'Enter') { event.preventDefault(); run(selected); }
|
||||
else if (event.key === 'Escape') { event.preventDefault(); onClose(); }
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
const tr = useT();
|
||||
let lastGroup = '';
|
||||
return createPortal(<div className="bt-palette-backdrop" onMouseDown={onClose}>
|
||||
<div className="bt-palette bt-pop" role="dialog" aria-label={tr('wb.palette.title')} onMouseDown={event => event.stopPropagation()} onKeyDown={onKeyDown}>
|
||||
<div className="bt-palette-field">
|
||||
<Icon name="search"/>
|
||||
<input ref={input} className="bt-palette-input" placeholder={site().palette.placeholder()} value={query}
|
||||
onChange={event => setQuery(event.target.value)} role="combobox" aria-expanded="true" aria-controls="bt-palette-list"
|
||||
aria-activedescendant={items[selected] ? `bt-pal-${selected}` : undefined} spellCheck={false}/>
|
||||
<kbd className="bt-kbd">Esc</kbd>
|
||||
</div>
|
||||
<div className="bt-palette-list" id="bt-palette-list" role="listbox" ref={list}>
|
||||
{items.length === 0 && <div className="bt-palette-empty">{tr('wb.palette.none')}</div>}
|
||||
{items.map((item, index) => {
|
||||
const heading = item.group !== lastGroup ? item.group : null;
|
||||
lastGroup = item.group;
|
||||
return <div key={item.id}>
|
||||
{heading && <div className="bt-palette-group">{groupLabel(heading)}</div>}
|
||||
<div id={`bt-pal-${index}`} role="option" aria-selected={index === selected} className="bt-palette-item"
|
||||
onMouseMove={() => { if (selected !== index) setSelected(index); }} onClick={() => run(index)}>
|
||||
<span className="bt-palette-icon">{leading(item, context)}</span>
|
||||
<span className="bt-palette-label">{item.label}</span>
|
||||
{item.hint && <span className={`bt-palette-hint ${item.hint === tr('wb.palette.active') ? 'bt-palette-hint--tag' : ''}`}>{shortHint(item.hint)}</span>}
|
||||
<span className="bt-palette-enter" aria-hidden="true">↵</span>
|
||||
</div>
|
||||
</div>;
|
||||
})}
|
||||
</div>
|
||||
<div className="bt-palette-foot">
|
||||
<span><kbd className="bt-kbd">↑</kbd><kbd className="bt-kbd">↓</kbd> {tr('wb.palette.navigate')}</span>
|
||||
<span><kbd className="bt-kbd">↵</kbd> {tr('wb.common.open')}</span>
|
||||
<span><kbd className="bt-kbd">{tr('wb.web.tabKey')}</kbd> {tr('wb.palette.next')}</span>
|
||||
<span className="bt-palette-foot-end"><kbd className="bt-kbd">Ctrl</kbd><kbd className="bt-kbd">K</kbd> {tr('wb.common.close')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>, document.body);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue