Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/demo/src/i18n.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import zhCN, { type TranslationKey } from './locales/zh-CN'
import enUS from './locales/en-US'

// 受支持语言列表与联合类型
export const SUPPORTED_LOCALES = ['zh-CN', 'en-US'] as const
export type Locale = (typeof SUPPORTED_LOCALES)[number]

// 首次加载固定使用简体中文,不做持久化与浏览器语言探测
export const DEFAULT_LOCALE: Locale = 'zh-CN'

const resources: Record<Locale, Record<TranslationKey, string>> = {
'zh-CN': zhCN,
'en-US': enUS
}

// 模块内语言状态,仅由 setLocale 修改
let currentLocale: Locale = DEFAULT_LOCALE

export function getLocale(): Locale {
return currentLocale
}

export function setLocale(locale: Locale): void {
currentLocale = locale
}

export function t(key: TranslationKey): string {
const text = resources[currentLocale][key]
if (!text) {
throw new Error(
`Missing translation for "${key}" in locale "${currentLocale}"`
)
}
return text
}
23 changes: 23 additions & 0 deletions apps/demo/src/locales/en-US.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import type { TranslationKey } from './zh-CN'

// 英文语言资源:使用 Record<TranslationKey, string> 约束,缺失键、多余键或非字符串值会在编译期报错
const enUS: Record<TranslationKey, string> = {
'hero.title': 'Multi Drag Workspace Demo',
'hero.subtitle':
'Showcases common gesture combinations of the web wrapper @system-ui-js/multi-drag and its host extension points.',
'demo.title': 'Web Wrapper Example',
'demo.description':
'Keeps the core Drag / Mixin usage and supports configuring single-finger actions.',
'demo.item1': 'Item 1 (Drag + Scale, single-finger Drag only)',
'demo.item2': 'Item 2 (Drag)',
'demo.item3': 'Item 3 (Drag + Rotate + Scale, single-finger Scale + Rotate)',
'host.title': 'Host Extension Points',
'host.description': 'Continues to support getPose / setPose / setPoseOnEnd.',
'pressure.title': 'Pressure-Sensitive Drag Demo',
'pressure.description':
'Drag with a stylus or a Force Touch trackpad and watch how pressure affects the visuals.',
'pressure.label': 'Pressure-Sensitive Drag',
'languageSwitcher.label': 'Switch language'
}

export default enUS
23 changes: 23 additions & 0 deletions apps/demo/src/locales/zh-CN.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// 简体中文语言资源:作为资源结构基准,其他语言通过 TranslationKey 校验完整性
const zhCN = {
'hero.title': 'Multi Drag Workspace Demo',
'hero.subtitle':
'展示 Web 封装 @system-ui-js/multi-drag 的常用手势组合与宿主扩展点。',
'demo.title': 'Web 封装示例',
'demo.description': '保持 Drag / Mixin 核心用法,并支持配置单指动作。',
'demo.item1': 'Item 1 (Drag + Scale,单指仅 Drag)',
'demo.item2': 'Item 2 (Drag)',
'demo.item3': 'Item 3 (Drag + Rotate + Scale,单指 Scale + Rotate)',
'host.title': '宿主扩展点兼容',
'host.description': '继续支持 getPose / setPose / setPoseOnEnd。',
'pressure.title': '压力敏感拖拽演示',
'pressure.description':
'使用触摸笔或 Force Touch 触摸板拖拽,观察压力对视觉效果的影响。',
'pressure.label': '压力敏感拖拽',
'languageSwitcher.label': '切换语言'
}

// 资源键联合类型:英文资源通过 Record<TranslationKey, string> 在编译期校验完整对应
export type TranslationKey = keyof typeof zhCN

export default zhCN
82 changes: 70 additions & 12 deletions apps/demo/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
} from '@system-ui-js/multi-drag'
import log from 'loglevel'
import VConsole from 'vconsole'
import { getLocale, setLocale, t, type Locale } from './i18n'
import type { TranslationKey } from './locales/zh-CN'

if (import.meta.env.DEV) {
log.setLevel('trace')
Expand All @@ -22,29 +24,47 @@ if (!appElement) {
throw new Error('Missing #app container')
}

// 页面 DOM 只创建一次:所有受国际化管理的文本节点携带稳定的 data-i18n 标记,
// 语言切换时仅原位更新 textContent,保证拖拽实例与事件绑定始终有效。
// 键通过类型约束的辅助函数写入模板,避免拼写出未知资源键。
const i18nAttrs = (key: TranslationKey) => `data-i18n="${key}"`

appElement.innerHTML = `
<div class="page">
<header class="hero">
<h1>Multi Drag Workspace Demo</h1>
<p>展示 Web 封装 @system-ui-js/multi-drag 的常用手势组合与宿主扩展点。</p>
<div class="hero-top">
<div class="hero-text">
<h1 ${i18nAttrs('hero.title')}>${t('hero.title')}</h1>
<p ${i18nAttrs('hero.subtitle')}>${t('hero.subtitle')}</p>
</div>
<div
class="language-switcher"
role="group"
data-i18n-aria-label="languageSwitcher.label"
aria-label="${t('languageSwitcher.label')}"
>
<button type="button" class="language-button" data-locale="zh-CN">中</button>
<button type="button" class="language-button" data-locale="en-US">EN</button>
</div>
</div>
</header>
<section class="panel">
<div>
<h2>Web 封装示例</h2>
<p>保持 Drag / Mixin 核心用法,并支持配置单指动作。</p>
<h2 ${i18nAttrs('demo.title')}>${t('demo.title')}</h2>
<p ${i18nAttrs('demo.description')}>${t('demo.description')}</p>
</div>
<div class="drag-zone">
<div class="drag-container">
<div class="draggable-item" id="item1">Item 1 (Drag + Scale,单指仅 Drag)</div>
<div class="draggable-item" id="item2">Item 2 (Drag)</div>
<div class="draggable-item" id="item3">Item 3 (Drag + Rotate + Scale,单指 Scale + Rotate)</div>
<div class="draggable-item" id="item1" ${i18nAttrs('demo.item1')}>${t('demo.item1')}</div>
<div class="draggable-item" id="item2" ${i18nAttrs('demo.item2')}>${t('demo.item2')}</div>
<div class="draggable-item" id="item3" ${i18nAttrs('demo.item3')}>${t('demo.item3')}</div>
</div>
</div>
</section>
<section class="panel">
<div>
<h2>宿主扩展点兼容</h2>
<p>继续支持 getPose / setPose / setPoseOnEnd。</p>
<h2 ${i18nAttrs('host.title')}>${t('host.title')}</h2>
<p ${i18nAttrs('host.description')}>${t('host.description')}</p>
</div>
<div class="drag-zone">
<div class="drag-container joysticks">
Expand All @@ -59,13 +79,13 @@ appElement.innerHTML = `
</section>
<section class="panel">
<div>
<h2>压力敏感拖拽演示</h2>
<p>使用触摸笔或 Force Touch 触摸板拖拽,观察压力对视觉效果的影响。</p>
<h2 ${i18nAttrs('pressure.title')}>${t('pressure.title')}</h2>
<p ${i18nAttrs('pressure.description')}>${t('pressure.description')}</p>
</div>
<div class="drag-zone">
<div class="drag-container">
<div class="draggable-item pressure-item" id="pressure-item">
<span class="pressure-label">压力敏感拖拽</span>
<span class="pressure-label" ${i18nAttrs('pressure.label')}>${t('pressure.label')}</span>
<span class="pressure-value" id="pressure-value">0.50</span>
</div>
</div>
Expand All @@ -74,6 +94,44 @@ appElement.innerHTML = `
</div>
`

// 语言切换的页面级刷新:只更新文案节点、html.lang、按钮当前态与 aria-pressed,
// 不重建任何 DOM,拖拽位置与交互状态在切换后保持不变。
const applyLocale = (locale: Locale) => {
setLocale(locale)
document.documentElement.lang = locale

document.querySelectorAll<HTMLElement>('[data-i18n]').forEach((element) => {
const key = element.dataset.i18n as TranslationKey
element.textContent = t(key)
})

document
.querySelectorAll<HTMLElement>('[data-i18n-aria-label]')
.forEach((element) => {
const key = element.dataset.i18nAriaLabel as TranslationKey
element.setAttribute('aria-label', t(key))
})

document
.querySelectorAll<HTMLButtonElement>('[data-locale]')
.forEach((button) => {
const isActive = button.dataset.locale === locale
button.classList.toggle('is-active', isActive)
button.setAttribute('aria-pressed', String(isActive))
})
}

document
.querySelectorAll<HTMLButtonElement>('[data-locale]')
.forEach((button) => {
button.addEventListener('click', () => {
applyLocale(button.dataset.locale as Locale)
})
})

// 初始化按钮当前态与 html.lang(初始语言固定为 zh-CN)
applyLocale(getLocale())

const item1 = document.getElementById('item1') as HTMLElement
const item2 = document.getElementById('item2') as HTMLElement
const item3 = document.getElementById('item3') as HTMLElement
Expand Down
68 changes: 68 additions & 0 deletions apps/demo/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,74 @@ body {
margin: 0 0 8px;
}

/* Hero 顶部布局:标题区与语言切换控件,窄屏允许换行避免重叠 */
.hero-top {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}

.hero-text {
flex: 1 1 320px;
min-width: 0;
}

/* 语言切换按钮组:沿用现有深色卡片与靛蓝描边体系 */
.language-switcher {
display: inline-flex;
gap: 4px;
padding: 4px;
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 999px;
background: rgba(30, 41, 59, 0.7);
}

.language-button {
appearance: none;
border: 1px solid transparent;
border-radius: 999px;
padding: 6px 14px;
background: transparent;
color: #c7d2fe;
font: inherit;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease-out, color 0.15s ease-out, border-color 0.15s ease-out;
}

.language-button:hover {
color: #eef2ff;
border-color: rgba(129, 140, 248, 0.55);
}

/* 键盘可达焦点样式 */
.language-button:focus-visible {
outline: 2px solid #818cf8;
outline-offset: 2px;
}

/* 当前语言的高对比 active 状态 */
.language-button.is-active {
background: #6366f1;
border-color: #818cf8;
color: #ffffff;
}

/* 窄屏:语言控件换行到标题下方,避免与长标题重叠 */
@media (max-width: 480px) {
.hero-top {
flex-direction: column;
align-items: stretch;
}

.language-switcher {
align-self: flex-start;
}
}

.drag-zone {
margin-top: 16px;
width: 100%;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-15
82 changes: 82 additions & 0 deletions openspec/changes/archive/2026-09-15-add-demo-i18n/design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
## Context

Demo 当前由 `apps/demo/src/main.ts` 一次性创建页面 DOM,并在随后获取固定元素、初始化 `Drag` / `Mixin` 实例以及注册压力事件监听器。国际化实现必须保持这些 DOM 节点及其事件绑定稳定,不能因语言切换重新创建拖拽区域。需求见 `specs/demo-internationalization/spec.md`,变更动机见 `proposal.md`。

Demo 使用原生 TypeScript、DOM API 和 Vite,没有 UI 框架,也没有现成的国际化依赖或测试脚本。因此实现应保持轻量,利用 TypeScript 约束两套资源的键和值完整对应。

## Goals / Non-Goals

**Goals:**

- 将 Demo 的用户可见文案集中到类型安全的 `zh-CN`、`en-US` 资源中。
- 提供读取当前语言、获取翻译和切换语言的最小 API。
- 语言切换只更新文案及切换控件状态,不替换已经绑定拖拽行为的 DOM 节点。
- 通过语义化按钮、当前态和 `aria-pressed` 提供可操作、可感知的语言切换入口。

**Non-Goals:**

- 不引入第三方 i18n 运行时、按需加载、复数规则、插值、日期或数字格式化能力。
- 不持久化语言选择,也不根据浏览器语言自动选择;首次加载固定使用 `zh-CN`,行为可预测且与当前以中文为主的界面一致。
- 不修改拖拽库 API、拖拽参数或拖拽实例的生命周期。

## Decisions

### 1. 使用静态资源模块和联合类型约束资源键

在 `apps/demo/src/locales/` 下分别维护 `zh-CN.ts` 和 `en-US.ts`。中文资源作为资源结构的基准,导出其键联合类型;英文资源使用 `Record<TranslationKey, string>`(或等价的 `satisfies` 约束)校验,确保编译时发现缺失键、多余键和非字符串值。

相比单个嵌套对象,独立语言文件更便于维护和审阅;相比 JSON,TypeScript 模块无需额外的导入配置,并可直接获得静态键检查。当前文案规模较小,使用扁平的语义键(例如 `hero.title`、`pressure.description`)即可,避免引入嵌套查找逻辑。

### 2. i18n 模块只管理语言状态和文本访问

新增 `apps/demo/src/i18n.ts`,声明受支持语言联合类型和只读语言列表,并提供:

- `getLocale()`:读取当前语言;
- `setLocale(locale)`:切换到受支持语言;
- `t(key)`:从当前语言资源读取文本。

初始语言为 `zh-CN`。`setLocale` 只负责更新模块内状态,不直接操作页面,也不包含拖拽相关逻辑。调用方在切换后显式刷新页面文案,使状态模块可独立验证,且避免隐藏的 DOM 副作用。

备选方案是引入事件订阅或第三方 i18n 库;前者对目前唯一调用方增加了不必要的生命周期管理,后者违反无新增运行时依赖的约束,因此不采用。

### 3. 页面 DOM 只创建一次,切换时原位更新文本

`main.ts` 首次渲染时通过 `t()` 填充模板,并为所有国际化文本节点添加 `data-i18n` 标记。语言按钮使用稳定的 `data-locale` 标记。切换时执行一个页面级更新函数:

1. 调用 `setLocale` 更新语言状态;
2. 遍历 `[data-i18n]` 元素,根据标记调用 `t()` 并设置 `textContent`;
3. 更新 `document.documentElement.lang`;
4. 更新两个语言按钮的当前态样式和 `aria-pressed`。

如控件存在需要翻译的无障碍名称,则使用独立的属性标记更新 `aria-label`,不把 HTML 字符串写入翻译资源。

不采用切换后重设 `appElement.innerHTML` 的方案,因为这会销毁原有节点,使 `Drag` / `Mixin` 实例和事件监听器继续引用已脱离文档的元素,并破坏用户当前的拖拽位置与交互状态。原位更新 `textContent` 可保持全部节点身份和绑定不变。

### 4. 语言入口放入 Hero 顶部,并沿用现有视觉体系

Hero 内增加标题区与语言切换控件的布局容器。控件由两个原生 `button` 组成,显示“中”和“EN”,通过背景、边框或文字颜色区分当前项,同时支持键盘焦点样式。窄屏时布局允许换行,避免与标题重叠。

选择两个按钮而非自定义下拉菜单,是因为目前仅有两种语言;按钮组操作步骤更少,也能直接表达当前状态。具体颜色、圆角和间距沿用 Demo 现有深色、靛蓝色设计变量,不建立新的完整设计系统。

### 5. 验证以静态类型、构建和可重复的 DOM 行为检查为主

Demo 当前没有测试脚本。实现时至少运行 Demo 构建,使 TypeScript/Vite 验证资源键完整性和模块集成。另对页面执行人工或浏览器级检查:初始中文完整、双向切换后文案和按钮状态正确、切换无需刷新,以及切换前后所有拖拽示例仍可操作。

若现有仓库测试基础设施可以低成本复用,可增加针对纯 i18n 模块的单元测试;本变更不为 Demo 单独引入测试框架。

## Risks / Trade-offs

- [新增文案时只在一套资源中维护] → 使用共享的 `TranslationKey` 和 `Record` 类型让构建阶段失败。
- [动态标记包含未知资源键] → 在模板中以受类型约束的辅助函数生成标记,或在更新函数中对键做显式断言并在开发环境抛出清晰错误。
- [切换语言导致拖拽状态丢失] → 永不重新赋值页面根节点的 `innerHTML`,只原位更新文案节点与按钮属性。
- [英文文案变长导致布局拥挤] → 标题区允许换行,拖拽卡片保留弹性尺寸,并在窄屏下检查按钮与标题布局。
- [不持久化语言使刷新后恢复中文] → 这是本次刻意限定的范围;未来如有需求可在 `i18n.ts` 的初始化和 `setLocale` 中增加持久化,不改变资源结构或页面更新策略。

## Migration Plan

1. 新增两套语言资源和 i18n 状态模块。
2. 为现有模板文案替换为资源访问,并添加稳定的国际化标记与语言入口。
3. 添加语言切换后的原位刷新逻辑和控件样式。
4. 运行 Demo 构建并验证两种语言和拖拽交互。

该变更只影响 Demo,不涉及数据迁移或库版本兼容。若需回滚,可删除新增模块并恢复 `main.ts`、`style.css`;拖拽包本身无须回滚。
Loading
Loading