MorseCode Card 纵向工作区设计
日期:2026-07-16
状态:已确认
修订:2026-07-17,增加 Preview 放大、任意画布比例适配和显式换行规则
关联:GitHub issue #15
背景
当前 Card 页面把输入、模板、预览、样式和导出操作放在一个响应式网格中。功能完整,但同屏信息密度较高,模板选择在桌面端是纵向列表,页面也没有利用 VitePress layout: doc 提供的 Outline 来表达制作流程。
本次调整把 Card 改为同一文档页内的纵向工作区。用户通过上下滚动依次完成 Text、Theme、Style 三个阶段,不使用页签或多页面向导。三个阶段分别对应 VitePress Outline 中的三个二级标题和三个滚动吸附点。
目标
- 在一个
layout: doc页面内呈现 Text、Theme、Style 三个阶段。 - 三个阶段使用视口级滚动吸附,滚动结束时不能停在相邻阶段中间。
- 第一屏露出模板画廊,第二屏突出模板与大 Preview,第三屏提供紧凑 Preview 和样式编辑器。
- 使用同一个 Preview 组件实例,不复制或同步两个渲染器。
- 单击有效 Preview 时提供桌面遮罩模态和移动端近全屏预览。
- 横向、方形和纵向画布在 Theme、Style 和放大预览中完整显示,不裁剪。
- 用户输入的显式换行在普通文本、摩斯码、卡片和导出文件中双向保留。
- 保留 VitePress 对正文宽度、窄屏顶部 Outline 和宽屏右侧 Aside 的默认管理。
- 让三个阶段标题进入静态 HTML,并由 VitePress Outline 自动发现。
- 保持现有模板、图片、导出、分享、查询参数限制和本地隐私语义。
- 增加本地化的 SEO 说明内容,同时保证 Outline 只显示三个工作阶段。
非目标
- 不实现页签、路由式向导或必须按顺序完成的步骤锁定。
- 不使用 Fullscreen API、shared-element/FLIP 动画或浏览器 View Transitions。
- 不引入 shadcn-vue、Tailwind CSS、Iconify 或新的通用设计系统。
- 不引入轮播组件库;模板数量增长后再评估 Embla Carousel。
- 不兼容或迁移旧分享链接中的隐藏 Brand 配置。
- 不改变卡片模板 schema、SVG 场景解析、PNG/SVG 导出格式或图片隐私边界。
- 不突破 VitePress
doc正文列宽来创建全宽工作区。 - 不定制或复制 VitePress 的 Outline/Aside 组件。
- 不裁剪画布来填满 Preview,也不通过拉伸改变模板宽高比。
核心决策
页面入口
Card 页面继续只挂载一个 <CardGenerator />。CardGenerator 是页面级编排器,拥有唯一状态源、滚动阶段状态、Preview、图片请求、导出和分享流程。
Text、Theme、Style 在代码内部拆成职责明确的展示区域,但不作为互相独立的 Markdown 兄弟组件。这样可避免额外的 Provider/inject 协议,也能保证 Preview、错误状态和导出 revision 只有一份。
VitePress 布局
- 所有本地化 Card 路由使用
layout: doc。 - Card 只响应当前正文列宽,不自行判断右侧 Aside 是否存在。
- 窄屏不为 Outline 预留右侧空间;VitePress 自己显示顶部折叠式 Outline。
- 宽屏由 VitePress 自己显示常驻右侧 Aside。
- Outline 级别限定为二级标题。
SSR 与 Outline
包住整个生成器的 <ClientOnly> 将被移除。CardGenerator、三个阶段标题和初始空状态 Preview 参与 SSR。浏览器专属操作继续只在 onMounted、事件处理函数或显式浏览器守卫中执行。
三个阶段分别渲染真实且本地化的标题:
<h2 id="text"><h2 id="theme"><h2 id="style">
VitePress 1.6.4 的 Outline 从 .VPDoc DOM 查询带 ID 的标题,不区分标题来自 Markdown 还是 Vue 组件。构建后必须验证这三个组件内标题会出现在宽屏 Aside 和窄屏 Outline 菜单中。
若该验证失败,降级方案是在 CardGenerator 内增加紧凑的阶段导航。降级导航不会与可用的 VitePress Outline 同时显示。
页面结构
Text 阶段
Text 阶段是第一处滚动吸附位置,主要内容为双向文本编辑器:
- 同时显示普通文本和摩斯码两个输入框。
- 编辑任一输入框时,合法内容实时更新另一输入框。
- 不显示编码/解码按钮。
- 不显示 Player 或播放 controls。
- 屏幕底部露出下一阶段同一个 Template Gallery 的一部分,形成自然的后续提示。
现有 Convertor.vue 不直接嵌入 Card,因为它持有自己的内部状态、固定 ID、按钮和 Player。应提取共享的双向输入组件或共享输入逻辑,使 Convertor 和 CardGenerator 使用同一套编码、解码和校验行为。
Theme 阶段
Theme 阶段是第二处滚动吸附位置:
- 横向 Template Gallery 位于上方。
- 大尺寸 Preview 占据主要空间。
- Preview 下方显示样式调整、下载和分享操作。
- 单击有效 Preview 可打开模态放大预览。
- 用户可在此直接完成并导出,不必进入 Style 阶段。
- 选择模板只更新状态和 Preview,不自动滚动到 Style。
- “样式调整”按钮滚动到
#style。
Style 阶段
Style 阶段是第三处滚动吸附位置:
- 同一个 Preview 切换为紧凑尺寸,静态放在阶段上部。
- 紧凑 Preview 使用固定的黑色 media stage;纵向画布居中显示,并在两侧自然留黑。
- Preview 不使用 sticky。
- 下载和分享操作继续可见,用户调整后无需返回 Theme。
- Style Editor 使用剩余高度,并在内容过长时内部纵向滚动。
- 外层阶段保持稳定,不跟随 Editor 内容继续增长。
Style Editor 的内部滚动区必须具备清晰的边界和键盘可访问性。到达第三阶段并稳定落位后,页面必须允许继续向下访问 SEO 内容和页脚。
单一 Preview 布局
CardPreview 和 CardActions 组成唯一的 Preview 区域。Theme 和 Style 阶段分别保留大尺寸和紧凑尺寸的布局槽,但不各自渲染 Preview。
推荐使用固定的页面级 CSS Grid 轨道和预留槽位:
- Text、Theme、Style 各自占据确定的阶段轨道。
- Preview 区域是
CardGenerator的单一子节点。 - Text 激活时,Preview 区域保持在 Theme 的默认大尺寸槽位,并位于当前视口之外。
- Theme 激活时,Preview 区域放置在 Theme 的大尺寸槽位。
- Style 激活时,同一节点切换到 Style 的紧凑槽位。
- 各阶段的轨道高度和占位空间固定,Preview 切换槽位不会改变标题的文档位置。
Grid 区域切换是离散状态变化,不要求 Preview 在滚动过程中连续跟手移动。阶段落位后使用短时 opacity/scale/size transition 表达状态变化;prefers-reduced-motion 下不播放过渡。
Theme 和 Style 的 Preview 槽位都使用统一的 contain 规则:
- 外层 media stage 使用稳定的布局尺寸和黑色背景。
- 内层 Preview 保持
canvas.width / canvas.height。 - Preview 同时受 stage 的最大 inline size 和 block size 约束。
- 纵向画布产生左右 letterbox,横向画布产生上下 letterbox。
- 画布比例切换不能改变 Style Editor 的可用区域或阶段标题位置。
不采用以下方案:
- 两个 Preview 实例:会增加同步、测量和可访问性风险。
- sticky Preview:Style Editor 已经内部滚动,不需要 sticky。
- fixed Preview:难以自然服从 VitePress 正文列和 Aside 布局。
- Teleport Preview:会增加 SSR、hydration、焦点和目标节点生命周期复杂度。
Preview 放大
组件边界
新增 CardLightbox,只负责原生 <dialog>、模态动画、关闭交互和临时资源释放。它不持有 Card 业务状态,也不渲染第二个主 CardPreview。
用户打开 Lightbox 时,CardGenerator 从当前唯一 Preview 序列化 SVG,并创建临时 SVG Blob URL。CardLightbox 使用 <img> 显示该快照。关闭后立即 revoke Blob URL。因此:
- 工作区始终只有一个主
CardPreview和一个有状态的主CardRenderer;Gallery 的无状态缩略图 renderer 保持现有职责。 - 模态内容与点击瞬间的 Preview 完全一致。
- 模态打开期间不会发生编辑,快照不需要继续响应状态变化。
- 不使用
v-html注入序列化 SVG。
仅当 Preview 内容有效、图片请求完成且渲染无错误时允许打开 Lightbox。创建快照失败时不打开 Dialog,并通过现有本地化状态区域报告预览失败。
桌面模态
- 使用
dialog.showModal()进入浏览器 top layer。 - Backdrop 覆盖 VitePress Nav、Sidebar、正文和右侧 Aside。
- Dialog 限制在当前 visual viewport 内,周围保留适量遮罩。
- 图片在黑色 stage 内最大化 contain,不裁剪。
移动端模态
- Dialog 使用
100dvw × 100dvh,不保留装饰性外边距、边框或圆角。 - 仅使用
env(safe-area-inset-*)避让系统安全区域。 - 图片按原始比例最大化 contain;只有画布比例无法覆盖的区域显示黑色。
- 不调用 Fullscreen API,不依赖浏览器权限或平台专属退出手势。
打开与关闭
- 单击有效 Preview 打开 Lightbox。
- Preview 可聚焦,按
Enter或Space也可打开。 - 打开动画使用约 200ms 的 backdrop opacity 和 Dialog content scale
0.94 → 1。 - 不显示可见的关闭图标或关闭文字按钮。
- 点击图片、黑色 stage 或桌面 backdrop 任意位置关闭。
- Dialog 的完整预览表面是具有本地化“关闭放大预览”名称的 dismiss control。
- Dialog 使用当前 Card 的可访问标题和描述;dismiss control 内的快照
<img>使用空alt,避免重复朗读图片与关闭动作。 - 聚焦 dismiss control 后,
Enter、Space和Escape均可关闭。 - 关闭动画与打开动画对称,完成后再调用
dialog.close()。 - 关闭后焦点返回触发打开的 Preview。
prefers-reduced-motion下不播放 backdrop 或 scale 动画,也不等待 transition end。
滚动模型
滚动所有权
三阶段外层滚动由浏览器窗口负责,不创建 Card 专属的外层滚动容器。VitePress 的 Outline 激活逻辑监听 window.scrollY,因此窗口滚动可以让框架自动更新当前标题。
Style Editor 是唯一允许的嵌套纵向滚动区。
吸附行为
- Text、Theme、Style 标题是三个 scroll-snap 目标。
- Card 工作区内使用纵向 mandatory snap。
- Outline 点击、浏览器 hash、普通滚轮/触摸滚动和“样式调整”按钮落到相同的目标位置。
- 滚动偏移必须考虑 VitePress Nav 和窄屏 Local Outline 的实际高度,不硬编码某个设备断点。
- 页面卸载时必须移除 Card 专属的根滚动 class 和 observer。
第一阶段的可视高度小于完整 Card 视口,差值用于露出 Theme Gallery。Theme 标题仍是第二处正式吸附起点,因此露出的画廊和第二屏顶部画廊是同一个 DOM 内容。
离开工作区
三阶段吸附只约束 Card 工作区:
- Text 或 Theme 激活时保持 mandatory snap。
- Style 到达目标位置并稳定后,解除根页面的强制吸附。
- 用户可继续滚动到 SEO 内容和 VitePress 页脚。
- 用户从下方重新进入 Card 区域时恢复三阶段吸附。
阶段切换由 IntersectionObserver 和最终位置容差共同判断。不能仅根据某个标题刚进入视口就提前解除 Style 吸附。
阶段引导
- VitePress Outline 提供任意阶段跳转。
- 第一屏露出的 Gallery 提供隐式下一步提示。
- 第二屏提供明确的“样式调整”按钮。
- 不在每一屏添加常驻向下箭头。
- 不把 Preview 添加为第四个 Outline 项或第四个吸附点。
“样式调整”是带 Lucide 调节图标和文本的次要命令按钮。它与下载、分享位于同一动作区,但视觉层级低于完成类操作。
双向输入
useCardGenerator() 继续持有:
sourceTextmorseCodeinputMode
inputMode 表示最后编辑的输入字段,并继续决定分享 URL 使用 text 还是 code。共享输入组件分别发出普通文本和摩斯码事件;编排器先更新输入模式,再调用对应转换逻辑。
合法输入实时更新另一字段。非法输入保留在正在编辑的字段,不覆盖另一字段,也不生成看似有效的新导出内容。
显式换行
模型入口统一将 CRLF 和单独的 CR 规范化为 LF。显式 LF 是内容语义的一部分:
- 普通空格、Tab 和其他行内空白继续折叠为单个 Morse 单词分隔符
/。 - 普通文本中的
LF编码为 Morse 字符串中的真实LF,而不是/。 - Morse 字符串中的
LF解码为普通文本中的真实LF。 - 连续、开头和结尾的
LF均保留。 - 分享查询参数继续保存原始换行,
URLSearchParams负责转义。
示例:
HELLO
WORLD编码为:
.... . .-.. .-.. ---
.-- --- .-. .-.. -..fitText() 先按显式换行切分硬行,再在每个非空硬行内部执行现有的自动换行。空白硬行产生一个空的视觉行,并消耗一行 line height。显式行和自动换行的总数共同受模板 maxLines 与 frame height 限制;先按现有算法缩小字体,最小字号仍放不下时报告 text-overflow。
Source TextElement 和 morse.literal generator 使用相同的硬换行排版规则,确保普通文本、摩斯码、SVG 和 PNG 的结果一致。
Template Gallery
现有 TemplateGallery.vue 改为所有正文宽度下的横向画廊:
- 原生
overflow-x和 CSS horizontal scroll snap。 - 触摸拖动和触控板横向滚动。
- Lucide 前后图标按钮,供鼠标和键盘用户使用。
- 不循环,不自动播放。
- 选中项使用
aria-pressed和明确边框状态。 - 选中模板后确保选中项进入可视区域。
当前只有三个模板,不引入 Embla。若模板数量或交互需求增长到需要循环、复杂拖拽、分页状态或插件,再单独评估 Embla。
缩略图数据规则:
- 内容为空时使用
SOS示例。 - 内容合法时使用当前普通文本和摩斯码。
- 当前选中模板使用实时样式状态。
- 未选中模板使用各自默认 palette、font、background 和 visibility。
- 未选中模板不复制用户上传图片。
- 所有缩略图只渲染 SVG,不提前栅格化 PNG。
样式与 Brand
- Brand 和 Logo 始终显示。
- Logo 使用自包含的场景节点生成,不引用站点外部 SVG URL;独立 SVG/PNG 导出继续包含完整品牌标记。
- Logo 可通过新增
brand.mark场景 generator 输出现有SceneNode,无需升级模板 schema。 - Brand 不出现在 visibility controls 中。
- Brand 不进入新的
hidden查询参数。 - 不为旧分享链接中的隐藏 Brand 行为提供兼容、迁移或专项测试。
- 其他内容可见性、palette、font、background 和图片控件保持现有模板约束。
依赖决策
Lucide
继续使用现有 lucide-vue-next。新增的 Gallery 前后按钮和“样式调整”按钮从 Lucide 选取一致的线性图标。
Iconify
不引入 @iconify/vue。当前只需要少量静态工具图标,Iconify 的大量图标集合和运行时加载能力不能抵消 SSR、第三方 API 或额外离线打包配置的成本。
shadcn-vue
不引入 shadcn-vue。当前项目没有 Tailwind,且 VitePress 已提供颜色变量和布局体系。为了 Carousel、Button 或 ScrollArea 引入 Tailwind、全局 CSS 变量和组件生成流程会扩大改动范围。
状态与数据流
useCardGenerator() 是唯一业务状态源。组件关系如下:
MorseTextEditor ── text/morse events ──> CardGenerator
TemplateGallery ─ template selection ──> CardGenerator
CardSettings ───── style/image events ─> CardGenerator
│
├─> one CardPreview
├─> CardActions
└─> share/export/query logic模板切换继续使用现有语义:清除旧模板专属的 palette、font、background override、visibility 和用户图片,然后应用新模板默认值。
布局阶段状态与 Card 业务状态分离。Text/Theme/Style 激活状态只决定滚动 class、Preview 槽位和尺寸,不写入分享 URL。
错误处理
输入错误
- 错误显示在对应输入框附近,并通过
aria-describedby关联。 - Preview 显示明确的无效状态遮罩,避免同时展示非法输入和上一次有效转换结果。
- 用户仍可切换模板或进入 Style。
- 下载和分享保持禁用。
Preview 与导出错误
- 文字溢出、必需图片缺失或图片仍在加载时继续禁用导出和分享。
- 图片错误显示在对应 Style Editor 控件附近。
- 查询过长、未知模板、导出失败和分享失败继续使用现有本地化状态消息。
- 布局和阶段切换不清除业务错误;只有相关输入或配置变化才使已准备分享失效。
- Lightbox SVG 快照创建失败时不打开 Dialog,状态区域显示本地化错误,焦点保留在 Preview 触发器。
焦点
- Outline 或“样式调整”触发滚动后,不强制把焦点移动到 Preview。
- 键盘触发阶段跳转时,目标标题可获得程序化焦点,且不产生额外滚动。
- Lightbox 打开后焦点进入完整预览 dismiss control,关闭后返回原 Preview。
- 下载菜单、分享准备和错误后的焦点恢复继续遵循现有 CardActions 行为。
SEO 内容
- 保留每个本地化路由的唯一 H1、title 和 description。
- Text、Theme、Style 三个 H2 参与 SSR。
- 工具下方增加本地化的介绍、使用场景和本地隐私说明。
- SEO 区域标题使用 VitePress 的
ignore-header约定,避免出现在工作流 Outline 中。 - SEO 内容不能被 mandatory snap 阻挡。
- 不把功能说明或操作教程作为工作区内的常驻辅助文字。
响应式原则
- Card 以当前 VitePress 正文列为唯一外部宽度约束。
- 不根据是否存在 Aside 计算额外右侧空间。
- 固定格式区域使用明确的 grid tracks、aspect-ratio 和 min/max 约束。
- Preview media stage 使用 contain 和黑色 letterbox,支持任意模板画布比例。
- Gallery item 宽度、Preview 大小、输入框高度和动作区换行根据正文容器宽度调整。
- 窄屏动作按钮可以换行,但不能覆盖 Preview、Editor 或下一阶段内容。
- 使用
svh/dvh和实际框架顶部偏移处理移动浏览器地址栏变化。 - Editor 内部滚动必须在触摸设备上可用,并保留从第三阶段继续访问下方文档内容的路径。
可访问性
- 三个阶段使用语义化 section 和本地化 H2。
- Gallery 是带可访问名称的单选按钮组。
- 前后按钮使用图标、工具提示和明确的
aria-label。 - 当前模板使用
aria-pressed。 - 输入错误、图片错误和 Preview 错误使用现有可访问关联与 live 状态策略。
- 所有交互目标满足至少 44px 的触摸尺寸。
- 焦点样式保持可见。
- Preview 触发器与 Lightbox dismiss control 都有本地化可访问名称。
- Lightbox 无可见关闭按钮,但必须支持点击任意位置、
Enter、Space和Escape关闭。 - Native Dialog 负责模态焦点约束;关闭后恢复触发器焦点。
prefers-reduced-motion下关闭平滑滚动和 Preview 阶段过渡。- 页面不能因 mandatory snap、嵌套 Editor 或 Outline 跳转形成键盘或触摸滚动陷阱。
测试策略
单元测试
- 双输入字段合法同步和最后编辑模式。
- 非法输入保留及另一字段不被覆盖。
CRLF/CR规范化和文本/Morse 双向LF保留。- 连续、开头、结尾和空白硬行排版。
- 硬换行与自动换行共同参与字体缩放和 overflow 判断。
- 缩略图的当前内容、默认模板状态和空内容 fallback。
- Brand 不可隐藏且不被新 URL 序列化。
- 阶段识别与 Preview 大/小状态映射。
- Style 落位后解除吸附,返回 Card 区域后恢复吸附。
组件测试
CardGenerator只渲染一个CardPreview。- Lightbox 打开时不创建第二个主
CardPreview或有状态的主CardRenderer。 - Lightbox Blob URL 在关闭和卸载时释放。
- Preview 点击与键盘打开、任意位置关闭和焦点恢复。
- 三个 H2 的 ID、顺序和本地化标签正确。
- Template Gallery 横向控制、选中状态和可视滚动。
- “样式调整”按钮定位
#style。 - Style Editor 使用内部滚动,Preview 不使用 sticky。
- 纵向、横向和方形画布在 Theme、Style 和 Lightbox stage 中完整 contain。
- 无效输入显示 Preview 遮罩并禁用动作。
- 下载、分享、图片上传和焦点恢复现有测试保持通过。
SSR 与构建测试
- VitePress 构建成功。
- 构建 HTML 包含 H1 和 Text/Theme/Style 三个 H2。
- 初始渲染不产生 browser global 或 hydration 错误。
- 所有本地化 Card 路由使用
layout: doc。 - 所有路由包含相同的三个阶段结构和本地化标签。
E2E 测试
宽屏:
- 右侧 Outline 只显示 Text、Theme、Style。
- 点击 Outline 后落在对应吸附位置。
- 普通滚动不能停在三个阶段中间。
- Theme 使用大 Preview,Style 使用同一 DOM 的紧凑 Preview。
- 桌面 Lightbox 遮罩覆盖 VitePress 左右区域,图片不超出 visual viewport。
- Lightbox 打开/关闭、backdrop 点击、图片点击、Escape 和焦点恢复正确。
- Style Editor 滚动不移动已落位的外层阶段。
- Style 后可继续访问 SEO 内容和页脚。
窄屏:
- 页面不预留右侧 Aside 宽度。
- 顶部 Outline 菜单包含三个阶段并可跳转。
- Lightbox 接近全视口,仅安全区域和画布比例剩余区域留黑。
- 纵向卡片居中且两侧留黑,不被裁剪或拉伸。
- Gallery 可触摸横滑,按钮不会挤出正文宽度。
- 双输入、Preview、动作区和 Editor 不重叠。
- 移动浏览器视口高度变化后仍能落在正确阶段。
通用:
prefers-reduced-motion下没有平滑滚动或尺寸动画。prefers-reduced-motion下 Lightbox 立即打开和关闭。- 主动换行在分享 URL、SVG 和 PNG 中保持一致。
- 下载 SVG/PNG、准备分享、系统分享和下载 fallback 回归通过。
- 上传图片仍只保存在浏览器内存,不进入分享 URL。
验收标准
- Card 页面使用 VitePress
layout: doc,正文宽度不被 Card 自行扩大。 - Outline 在宽屏右侧和窄屏顶部菜单中都只包含 Text、Theme、Style。
- 三个吸附位置与三个 Outline 链接一致。
- Text 同时显示普通文本和摩斯码输入,且无 controls 或 Player。
- Theme 使用原生横向 Gallery 和大 Preview。
- 单击有效 Preview 可打开平滑缩放的原生 Dialog 放大预览。
- 移动端 Lightbox 接近全屏,画布按比例最大化且只使用黑色 letterbox。
- Lightbox 没有可见关闭按钮,点击图片或背景任意位置以及键盘操作均可关闭。
- 第二屏有明确的“样式调整”按钮。
- Style 使用同一个紧凑 Preview,且 Preview 不是 sticky。
- 紧凑 Preview 完整支持纵向、横向和方形画布,不裁剪或拉伸。
- Style Editor 内容过长时内部滚动。
- 用户显式换行在普通文本、Morse、Preview、分享 URL 和导出文件中双向保留。
- Brand 和 Logo 始终显示且没有 UI 开关。
- 不新增 Iconify、shadcn-vue、Tailwind 或 Carousel 依赖。
- 三个标题和 SEO 内容参与静态构建。
- 用户可以离开第三阶段继续访问 SEO 内容和页脚。
- 现有 Card 导出、分享、图片、安全限制和本地化测试继续通过。