Skip to content

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 布局

CardPreviewCardActions 组成唯一的 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 可聚焦,按 EnterSpace 也可打开。
  • 打开动画使用约 200ms 的 backdrop opacity 和 Dialog content scale 0.94 → 1
  • 不显示可见的关闭图标或关闭文字按钮。
  • 点击图片、黑色 stage 或桌面 backdrop 任意位置关闭。
  • Dialog 的完整预览表面是具有本地化“关闭放大预览”名称的 dismiss control。
  • Dialog 使用当前 Card 的可访问标题和描述;dismiss control 内的快照 <img> 使用空 alt,避免重复朗读图片与关闭动作。
  • 聚焦 dismiss control 后,EnterSpaceEscape 均可关闭。
  • 关闭动画与打开动画对称,完成后再调用 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() 继续持有:

  • sourceText
  • morseCode
  • inputMode

inputMode 表示最后编辑的输入字段,并继续决定分享 URL 使用 text 还是 code。共享输入组件分别发出普通文本和摩斯码事件;编排器先更新输入模式,再调用对应转换逻辑。

合法输入实时更新另一字段。非法输入保留在正在编辑的字段,不覆盖另一字段,也不生成看似有效的新导出内容。

显式换行

模型入口统一将 CRLF 和单独的 CR 规范化为 LF。显式 LF 是内容语义的一部分:

  • 普通空格、Tab 和其他行内空白继续折叠为单个 Morse 单词分隔符 /
  • 普通文本中的 LF 编码为 Morse 字符串中的真实 LF,而不是 /
  • Morse 字符串中的 LF 解码为普通文本中的真实 LF
  • 连续、开头和结尾的 LF 均保留。
  • 分享查询参数继续保存原始换行,URLSearchParams 负责转义。

示例:

text
HELLO
WORLD

编码为:

text
.... . .-.. .-.. ---
.-- --- .-. .-.. -..

fitText() 先按显式换行切分硬行,再在每个非空硬行内部执行现有的自动换行。空白硬行产生一个空的视觉行,并消耗一行 line height。显式行和自动换行的总数共同受模板 maxLines 与 frame height 限制;先按现有算法缩小字体,最小字号仍放不下时报告 text-overflow

Source TextElement 和 morse.literal generator 使用相同的硬换行排版规则,确保普通文本、摩斯码、SVG 和 PNG 的结果一致。

现有 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() 是唯一业务状态源。组件关系如下:

text
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 无可见关闭按钮,但必须支持点击任意位置、EnterSpaceEscape 关闭。
  • 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 导出、分享、图片、安全限制和本地化测试继续通过。