feat(sidebar): 桌面兼容三方案——自动检测(WCO 标准几何)/壳预设/自定义 CSS (#284)
* feat(sidebar): 桌面兼容三方案——自动检测(WCO 标准几何)/壳预设/自定义 CSS 核心只保留 Web 标准机制,壳专属适配全部下沉到用户空间(KISS): - prefs 新增 titleBarScheme(默认 auto)/titleBarPresetId/customCss,旧 titleBarCompat 布尔自动迁移(true→custom 保留 stripPx,false/缺失→auto) - wco.ts:navigator.windowControlsOverlay 反应式几何(geometrychange 实时更新; visible=false 的幽灵 API 视为缺失),实现 #257 - titlebar-strip.ts 唯一 strip 取值链:WCO → dsh-desktop-titlebar-inset 契约参数 → 预设 stripFor → 手动 px → 0;desktop-env.ts 移除 win32 32px 硬编码 - shell-presets.ts 数据驱动预设(准入:issue/PR 提及 + 100+ star;v1 仅 anywhere-labs DSH Desktop:darwin 20px / win32 无 WCO 时 32px),opt-in 手动 - 预设/自定义 CSS 注入(data-dsh-preset-css / data-dsh-custom-css 标签), chrome 补稳定寻址面 data-dsh-toggle-cluster / data-dsh-panel - 交互 chrome 统一 -webkit-app-region: no-drag(吸收 #111/#153) - layout.css 底栏推挤复合锚点([data-pane] 与 :has(> [data-slot]) 双保险, rc.8 实测前者失效、后者命中,吸收 #226 思路) - 设置 UI:位置兼容模式齿轮弹窗改三方案(自动检测/壳兼容方案/自定义方案) - 测试:wco/titlebar-strip/shell-presets 单测 + prefs 迁移 + e2e 5 场景 (保守 auto/WCO 反应式/预设/自定义 CSS/复合锚点),挂载门禁 12/12 全绿 核心 grep 无壳专属类名;为某壳做的兼容在另一壳会再坏——预设与自定义 CSS 是唯一出路(AGENTS.md §8 契约已更新)。 * refine(sidebar): 位置兼容模式改主行下拉四选项——自动检测/DSH官方Web/壳预设/自定义 按用户反馈定稿 UI: - 主行下拉:自动检测(默认)/ DSH官方Web(显式零适配,连 WCO 也强制 0)/ 各壳预设(命中环境带「已检测」后缀,仅提示)/ 自定义方案(保留齿轮弹窗: 下移距离 + 自定义 CSS) - 旧文档迁移:titleBarCompat=true 或 titleBarStripPx 非默认值(此前已有配置) → 默认落到 custom 方案保留用户数值;干净文档 → auto - prefs 新增 'web' 方案;strip 取值链最优先处理 web→0 - 测试:prefs 迁移(stripPx 48→custom / 40→auto)、web 强制 0、下拉渲染断言 - 单测 693 通过,挂载 e2e 12/12 全绿 * refine(sidebar): 方案下拉复用 SelectMenu 组件,不用原生 <select> - 抽出可复用 SelectMenu(primitives Menu:锚点按钮 + 下拉选项卡,支持 icon/desc/多选),SelectRow 改为其薄壳 - 位置兼容模式方案下拉改用 SelectMenu;选项带描述(自动检测/DSH官方Web/ 各壳预设(命中带「已检测」后缀)/自定义方案) - 移除原生 select 样式;单测 693 通过,挂载 e2e 12/12 全绿
This commit is contained in:
+205
-68
@@ -38,7 +38,7 @@
|
||||
* shows the wire error inline — a broken settings surface never crashes the
|
||||
* shell.
|
||||
*/
|
||||
import { Fragment, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { Fragment, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
|
||||
import {
|
||||
IconCheckOutline16,
|
||||
IconChevronDownOutline14,
|
||||
@@ -59,11 +59,14 @@ import {
|
||||
WIDTH_PERCENT_MAX,
|
||||
WIDTH_PERCENT_MIN,
|
||||
type SidebarPrefs,
|
||||
type TitleBarScheme,
|
||||
} from '../prefs-shared.ts'
|
||||
import { api } from './api.ts'
|
||||
import { parsePrefs } from './prefs.ts'
|
||||
import { AddPluginModal, type PluginKind } from './add-plugin-modal.tsx'
|
||||
import { t } from './locales.ts'
|
||||
import { parseDesktopEnv } from './desktop-env.ts'
|
||||
import { getShellPreset, getShellPresets } from './shell-presets.ts'
|
||||
import type { SidebarStore } from './state.ts'
|
||||
import type {
|
||||
BetterSidebarService,
|
||||
@@ -109,6 +112,17 @@ function tabOrder(a: TabDescriptor, b: TabDescriptor): number {
|
||||
return (a.order ?? 100) - (b.order ?? 100)
|
||||
}
|
||||
|
||||
/**
|
||||
* The scheme dropdown's current value: the plain scheme, or `preset:<id>`
|
||||
* while a preset is active. Falls back to `auto` when the stored preset id
|
||||
* is no longer registered (the strip resolves to 0 then anyway).
|
||||
*/
|
||||
function titleBarSchemeValue(prefs: SidebarPrefs): string {
|
||||
if (prefs.titleBarScheme !== 'preset') return prefs.titleBarScheme
|
||||
const preset = getShellPreset(prefs.titleBarPresetId)
|
||||
return preset !== undefined ? `preset:${preset.id}` : 'auto'
|
||||
}
|
||||
|
||||
/** Viewer inventory order: priority desc (the catch-all `code` comes last). */
|
||||
function viewerOrder(a: FileViewerDescriptor, b: FileViewerDescriptor): number {
|
||||
return (b.priority ?? 0) - (a.priority ?? 0)
|
||||
@@ -318,24 +332,60 @@ function TypedRow(props: {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/**
|
||||
* The multi-line custom-CSS input (scheme `custom`): a monospace textarea
|
||||
* whose draft is local state, committed on blur or Cmd/Ctrl+Enter through
|
||||
* the parent's handler. Keyed by the stored value so an external commit
|
||||
* remounts it with the canonical text (same pattern as TypedRow).
|
||||
*/
|
||||
function CssDraft(props: {
|
||||
value: string
|
||||
onCommit: (raw: string) => void
|
||||
label: string
|
||||
placeholder?: string
|
||||
}) {
|
||||
const { value, onCommit, label, placeholder } = props
|
||||
const [draft, setDraft] = useState(value)
|
||||
return (
|
||||
<textarea
|
||||
className={css.cssTextArea}
|
||||
rows={6}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
aria-label={label}
|
||||
spellCheck={false}
|
||||
onChange={event => { setDraft(event.currentTarget.value) }}
|
||||
onBlur={() => { onCommit(draft) }}
|
||||
onKeyDown={event => {
|
||||
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) event.currentTarget.blur()
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One select row: a dropdown over the toggle's declared `options`. When any
|
||||
* option carries an icon, the dropdown renders big-icon option cards (icon +
|
||||
* title + desc) and the closed anchor shows the selected option's icon as
|
||||
* well; without icons both are a single line of text. Single-pick commits the
|
||||
* option's value and closes; `multi` toggles membership, commits the picked
|
||||
* values as an array (in options order), and stays open.
|
||||
* The reusable dropdown — the primitives Menu, NOT a native <select>: a
|
||||
* closed anchor button (picked option text + chevron) opening one Menu item
|
||||
* per option (big-icon cards when any option carries an icon). Single-pick
|
||||
* commits the option's value and closes; `multi` toggles membership and
|
||||
* commits the picked values as an array (in options order), staying open.
|
||||
* Shared by the declarative select rows (SelectRow) and the title-bar
|
||||
* scheme dropdown on the General row.
|
||||
*/
|
||||
function SelectRow(props: {
|
||||
toggle: SidebarSettingToggle
|
||||
title: string
|
||||
function SelectMenu(props: {
|
||||
label: string
|
||||
value: unknown
|
||||
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
|
||||
options: readonly {
|
||||
value: string | number | boolean
|
||||
title: string | (() => string)
|
||||
desc?: string | (() => string)
|
||||
icon?: ReactNode | ((size: number) => ReactNode)
|
||||
}[]
|
||||
multi?: boolean
|
||||
onSelect: (next: unknown) => void
|
||||
placeholder?: string
|
||||
}) {
|
||||
const { toggle, title, value, onSelectValue } = props
|
||||
const options = toggle.options ?? []
|
||||
const multi = toggle.multi === true
|
||||
const { label, value, options, multi, onSelect, placeholder } = props
|
||||
const [open, setOpen] = useState(false)
|
||||
const hasIcons = options.some(option => option.icon !== undefined)
|
||||
const picked: readonly unknown[] = multi ? (Array.isArray(value) ? value : []) : [value]
|
||||
@@ -346,7 +396,7 @@ function SelectRow(props: {
|
||||
const option = options[index]
|
||||
if (option === undefined) return
|
||||
if (!multi) {
|
||||
onSelectValue?.(toggle, option.value)
|
||||
onSelect(option.value)
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
@@ -355,14 +405,14 @@ function SelectRow(props: {
|
||||
if (at >= 0) current.splice(at, 1)
|
||||
else current.push(option.value)
|
||||
// Stable wire order: follow the declared options order, not pick order.
|
||||
onSelectValue?.(toggle, options.filter(o => current.includes(o.value)).map(o => o.value))
|
||||
onSelect(options.filter(o => current.includes(o.value)).map(o => o.value))
|
||||
}
|
||||
|
||||
const anchor = (
|
||||
<button
|
||||
type="button"
|
||||
className={css.selectAnchor}
|
||||
aria-label={title}
|
||||
aria-label={label}
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={open}
|
||||
onClick={() => { setOpen(now => !now) }}
|
||||
@@ -371,12 +421,55 @@ function SelectRow(props: {
|
||||
<span className={css.selectAnchorIcon}>{iconOf(selected[0].icon, 16)}</span>
|
||||
)}
|
||||
<span className={css.selectAnchorText}>
|
||||
{selected.length === 0 ? '—' : selected.map(option => textOf(option.title)).join(', ')}
|
||||
{selected.length === 0 ? (placeholder ?? '—') : selected.map(option => textOf(option.title)).join(', ')}
|
||||
</span>
|
||||
<IconChevronDownOutline14 size={12} />
|
||||
</button>
|
||||
)
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
anchor={anchor}
|
||||
items={options.map((option, index) => ({
|
||||
id: String(index),
|
||||
label: hasIcons
|
||||
? (
|
||||
<span className={css.selectOption}>
|
||||
<span className={css.selectOptionIcon}>{iconOf(option.icon, 24)}</span>
|
||||
<span className={css.selectOptionText}>
|
||||
<span className={css.title}>{textOf(option.title)}</span>
|
||||
{textOf(option.desc) !== '' && <span className={css.desc}>{textOf(option.desc)}</span>}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
: textOf(option.title),
|
||||
}))}
|
||||
selectedId={!multi && selected[0] !== undefined ? String(options.indexOf(selected[0])) : undefined}
|
||||
selectedIds={multi ? selected.map(option => String(options.indexOf(option))) : undefined}
|
||||
onSelect={(id) => { pick(Number(id)) }}
|
||||
onClose={() => { setOpen(false) }}
|
||||
portal
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* One select row: a dropdown over the toggle's declared `options` (the
|
||||
* shared SelectMenu). When any option carries an icon, the dropdown renders
|
||||
* big-icon option cards (icon + title + desc) and the closed anchor shows
|
||||
* the selected option's icon as well; without icons both are a single line
|
||||
* of text. Single-pick commits the option's value and closes; `multi`
|
||||
* toggles membership, commits the picked values as an array (in options
|
||||
* order), and stays open.
|
||||
*/
|
||||
function SelectRow(props: {
|
||||
toggle: SidebarSettingToggle
|
||||
title: string
|
||||
value: unknown
|
||||
onSelectValue?: (toggle: SidebarSettingToggle, next: unknown) => void
|
||||
}) {
|
||||
const { toggle, title, value, onSelectValue } = props
|
||||
return (
|
||||
<div className={css.popupRow}>
|
||||
<span className={css.rowText}>
|
||||
@@ -384,28 +477,12 @@ function SelectRow(props: {
|
||||
{textOf(toggle.desc) !== '' && <span className={css.desc}>{textOf(toggle.desc)}</span>}
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
<Menu
|
||||
open={open}
|
||||
anchor={anchor}
|
||||
items={options.map((option, index) => ({
|
||||
id: String(index),
|
||||
label: hasIcons
|
||||
? (
|
||||
<span className={css.selectOption}>
|
||||
<span className={css.selectOptionIcon}>{iconOf(option.icon, 24)}</span>
|
||||
<span className={css.selectOptionText}>
|
||||
<span className={css.title}>{textOf(option.title)}</span>
|
||||
{textOf(option.desc) !== '' && <span className={css.desc}>{textOf(option.desc)}</span>}
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
: textOf(option.title),
|
||||
}))}
|
||||
selectedId={!multi && selected[0] !== undefined ? String(options.indexOf(selected[0])) : undefined}
|
||||
selectedIds={multi ? selected.map(option => String(options.indexOf(option))) : undefined}
|
||||
onSelect={(id) => { pick(Number(id)) }}
|
||||
onClose={() => { setOpen(false) }}
|
||||
portal
|
||||
<SelectMenu
|
||||
label={title}
|
||||
value={value}
|
||||
options={toggle.options ?? []}
|
||||
multi={toggle.multi === true}
|
||||
onSelect={(next) => { onSelectValue?.(toggle, next) }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
@@ -498,6 +575,10 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
const [settingsFor, setSettingsFor] = useState<TabDescriptor | FileViewerDescriptor | null>(null)
|
||||
// Whether the position-compat strip popup (the gear on the 常规 row) is open.
|
||||
const [stripSettingsOpen, setStripSettingsOpen] = useState(false)
|
||||
// The parsed desktop environment (URL stamps — see desktop-env.ts). Used
|
||||
// ONLY to badge matching presets in the scheme dropdown ("已检测");
|
||||
// nothing is auto-applied.
|
||||
const detectedEnv = useMemo(() => parseDesktopEnv(), [])
|
||||
// Whether the "add plugin" modal (a dashed card at the end of the
|
||||
// 侧边栏内容 / 文件预览 grids) is open, and for which extension point
|
||||
// (null = closed).
|
||||
@@ -635,6 +716,38 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the title-bar / shell compatibility scheme. Mirrors the legacy
|
||||
* `titleBarCompat` flag (true = anything but the conservative auto) so
|
||||
* documents stay readable by older plugin versions.
|
||||
*/
|
||||
/**
|
||||
* Pick the title-bar / shell compatibility scheme from the dropdown. The
|
||||
* option values are `auto` | `web` | `custom` | `preset:<id>`; selecting
|
||||
* a preset stores both the scheme and its id. Mirrors the legacy
|
||||
* `titleBarCompat` flag (true for preset/custom) so documents stay
|
||||
* readable by older plugin versions.
|
||||
*/
|
||||
const onSchemeSelect = (value: unknown): void => {
|
||||
if (typeof value !== 'string') return
|
||||
if (value === 'auto' || value === 'web' || value === 'custom') {
|
||||
applyPref({ titleBarScheme: value, titleBarCompat: value === 'custom' })
|
||||
return
|
||||
}
|
||||
if (value.startsWith('preset:') && getShellPreset(value.slice('preset:'.length)) !== undefined) {
|
||||
applyPref({
|
||||
titleBarScheme: 'preset',
|
||||
titleBarPresetId: value.slice('preset:'.length),
|
||||
titleBarCompat: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Commit the free-form custom CSS (scheme `custom`). */
|
||||
const commitCustomCss = (raw: string): void => {
|
||||
applyPref({ customCss: raw })
|
||||
}
|
||||
|
||||
/** Persist one plugin-owned setting of one descriptor (merged into the pluginSettings blob). */
|
||||
const applyPluginSetting = (descriptorId: string, key: string, value: unknown): void => {
|
||||
applyPref({ pluginSettings: mergePluginSetting(optimisticRef.current.pluginSettings, descriptorId, key, value) })
|
||||
@@ -800,12 +913,31 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
</span>
|
||||
<span className={css.control}>
|
||||
{/*
|
||||
The position-compat row's gear (same popup pattern as the
|
||||
feature cards): opens a Modal with the strip-height number row.
|
||||
Hidden while the mode is off — its related setting is dormant
|
||||
then (the feature-card convention).
|
||||
The scheme dropdown (the shared SelectMenu — NOT a native
|
||||
select): 自动检测 (default) / DSH官方Web / 各壳兼容方案 /
|
||||
自定义方案. Matching presets carry a 「已检测」 desc badge
|
||||
(suggestion only). The 自定义方案 row keeps its gear (the
|
||||
popup with the shift distance + custom CSS) — the other
|
||||
schemes need no further settings.
|
||||
*/}
|
||||
{prefs.titleBarCompat && (
|
||||
<SelectMenu
|
||||
label={t('settingsTitleBarTitle')}
|
||||
value={titleBarSchemeValue(prefs)}
|
||||
options={[
|
||||
{ value: 'auto', title: t('settingsSchemeAutoTitle'), desc: t('settingsSchemeAutoDesc') },
|
||||
{ value: 'web', title: t('settingsSchemeWebTitle'), desc: t('settingsSchemeWebDesc') },
|
||||
...getShellPresets().map(preset => ({
|
||||
value: `preset:${preset.id}`,
|
||||
title: preset.title,
|
||||
desc: preset.detect?.(detectedEnv) === true
|
||||
? `${preset.desc}(${t('settingsSchemeDetectedSuffix')})`
|
||||
: preset.desc,
|
||||
})),
|
||||
{ value: 'custom', title: t('settingsSchemeCustomTitle'), desc: t('settingsSchemeCustomDesc') },
|
||||
]}
|
||||
onSelect={onSchemeSelect}
|
||||
/>
|
||||
{prefs.titleBarScheme === 'custom' && (
|
||||
<button
|
||||
type="button"
|
||||
className={css.rowGear}
|
||||
@@ -816,11 +948,6 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
<IconSettingsOutline16 size={14} />
|
||||
</button>
|
||||
)}
|
||||
<Switch
|
||||
label={t('settingsTitleBarTitle')}
|
||||
checked={prefs.titleBarCompat}
|
||||
onChange={(next) => { applyPref({ titleBarCompat: next }) }}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -948,10 +1075,11 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{/* The position-compat strip popup (opened by the gear on the 常规
|
||||
row): one number row for the reserved strip height in px. Same
|
||||
Modal chrome and row machinery as the feature popups — mounted
|
||||
only while open (the Modal SSR rule above). */}
|
||||
{/* The custom-scheme popup (opened by the gear next to the scheme
|
||||
dropdown when 自定义方案 is active): the shift distance in px and
|
||||
the free-form custom CSS. The OTHER schemes (自动检测 / DSH官方Web /
|
||||
壳预设) need no further settings — the scheme itself is chosen on
|
||||
the 常规 row. Mounted only while open (the Modal SSR rule above). */}
|
||||
{stripSettingsOpen && (
|
||||
<Modal
|
||||
open
|
||||
@@ -966,20 +1094,29 @@ export function SideCardSection({ store, service }: SideCardSectionProps) {
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<FeatureSettingsRows
|
||||
toggles={[{
|
||||
key: 'titleBarStripPx',
|
||||
type: 'number',
|
||||
title: () => t('settingsTitleBarStripTitle'),
|
||||
desc: () => t('settingsTitleBarStripDesc'),
|
||||
min: TITLE_BAR_STRIP_MIN,
|
||||
max: TITLE_BAR_STRIP_MAX,
|
||||
unit: 'px',
|
||||
}]}
|
||||
prefs={prefs}
|
||||
onToggle={onToggleSetting}
|
||||
onCommit={onCommitSetting}
|
||||
/>
|
||||
<div className={css.popupRows}>
|
||||
<FeatureSettingsRows
|
||||
toggles={[{
|
||||
key: 'titleBarStripPx',
|
||||
type: 'number',
|
||||
title: () => t('settingsTitleBarStripTitle'),
|
||||
desc: () => t('settingsTitleBarStripDesc'),
|
||||
min: TITLE_BAR_STRIP_MIN,
|
||||
max: TITLE_BAR_STRIP_MAX,
|
||||
unit: 'px',
|
||||
}]}
|
||||
prefs={prefs}
|
||||
onToggle={onToggleSetting}
|
||||
onCommit={onCommitSetting}
|
||||
/>
|
||||
<CssDraft
|
||||
key={prefs.customCss}
|
||||
value={prefs.customCss}
|
||||
label={t('settingsCustomCssTitle')}
|
||||
placeholder={t('settingsCustomCssPlaceholder')}
|
||||
onCommit={commitCustomCss}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user