Skip to content

feat: toolbar table/emoji, slash menu reordering, and table fixes - #361

Open
DC911360 wants to merge 21 commits into
floatboatai:mainfrom
DC911360:main
Open

DC911360 wants to merge 21 commits into
floatboatai:mainfrom
DC911360:main

Conversation

@DC911360

@DC911360 DC911360 commented Sep 21, 2026 •

Copy link
Copy Markdown

Summary / 摘要

Adds table and emoji insertion to the toolbar, opt-in drag-to-reorder for slash menu commands, and fixes three table-widget defects found while exercising them. 新增工具栏的表格与 emoji 插入、斜杠菜单可拖拽排序(opt-in),并修掉使用过程中暴露的三个表格 widget 缺陷。

Note on scope / 关于范围:this bundles three independent concerns. They are described under separate headings below and each carries its own OpenSpec change where one is required — happy to split into separate PRs on request. 本 PR 合并了三件独立的事,下面分节说明;需要的话可以拆成多个 PR。


Motivation / 背景与动机

  • Issue: —
  • Roadmap (docs/ROADMAP.md): #12 高级 toolbar(emoji picker / 表格工具 / 颜色选择)· #9 Widget API
    标准化(相关背景)· #25 E2E 测试(表格回归的动机)
  • OpenSpec change: add-slash-menu-reorder · add-toolbar-table-insert · add-toolbar-emoji-picker

Roadmap #12 planned an emoji picker, table tools, and a colour picker; the colour half shipped earlier, leaving the toolbar unable to produce a table or an emoji. Separately, the table widget could not be reordered into shape from the UI, and exercising it surfaced defects in how cell edits reach the document.

Roadmap #12 规划了 emoji picker / 表格工具 / 颜色选择,部分此前已落地,工具栏却仍无法产出表格或 emoji。另外表格 widget 无法从界面上增删行列,实测过程中又暴露出单元格编辑写回文档的缺陷。


Changes / 变更内容

1. Slash menu drag-to-reorder — opt-in(add-slash-menu-reorder)

  • packages/plugin-slash:
    • 新增 command-order.ts,与既有的 command-history.ts 采用同一套宿主注入存储契约:true 为会话内生效,{ storage, storageKey } 为宿主持久化,不隐式写 localStorage
    • menu-ui.ts:每个 item 前置拖拽手柄(内联 SVG 点阵),使用自写 mousedown/mousemove/mouseup 拖拽 —— 刻意不用 HTML5 Drag API(沿用仓库既有约定)
    • 拖拽范围钳制在渲染出的行内;itemEls、visibleCommands、DOM 与高亮索引同步移动,回车永远执行用户看到的那一条
    • FLIP 动画(测量 → 重排 → 反向位移 → 释放),尊重 prefers-reduced-motion;被拖拽行跟随指针
    • 手动顺序叠加在「最近使用」排序之后,pin 过的命令优先
    • 编辑器在渲染前截断结果(slashMenuLimit),因此写回时只重写可见 id,窗口外的 id 保持原有相对顺序
    • 顺带修正一处既有隐患:item 的 hover/click 回调在创建时捕获 index,重排后即失效;改为事件时解析
  • apps/electron-demo: 打开该开关(库默认仍关闭),并补手柄样式

2. Table & emoji insertion in the toolbar

  • packages/plugin-toolbar:
    • insertTable(editor, rows, cols) —— 单事务写入 GFM 表格,一次 undo 撤掉整张表;rows 含表头,与选择器画出的形状一致
    • 10×10 尺寸网格:悬停高亮 + N x M 读数,点击插入
    • insertEmoji(editor, emoji) + 精选 emoji 集合(4 类 50 个,EMOJI_CATEGORIES 对外导出)
    • 图标:新增 iconTable / iconEmoji,并把 link 图标由「椭圆+横线」重绘为对角链条(原图标读不出「链接」的含义)
    • 两个 picker 均复用既有 dropdown 机制(DROPDOWN_IDS / DROPDOWN_STYLES / 外部点击关闭 / closeDropdown)
  • openspec/: add-toolbar-table-insert、add-toolbar-emoji-picker

不引入任何运行时依赖。 emoji 采用精选硬编码而非全量 Unicode 表 —— 后者是很大的运行时资产,引入它会让改动先卡在依赖许可审查上(GOVERNANCE.md §6.3)。提案中已列为 Non-Goal。

3. Table widget: row/column actions and three fixes

  • packages/core:
    • 行/列删除按钮:每列 grip 上方、每个数据行 grip 左侧各一个垃圾桶按钮,随 hover 出现。它们阻断 mousedown 与 click —— grip 自身拥有拖拽(mousedown)与选中列(click),不阻断就会误触发。最后一列保留右键菜单既有的「不可删」保护
    • 自适应宽度:右键菜单新增一项,撤销手动拖宽(移除 applyColumnWidths 装上的 <colgroup> / fixed / 显式宽度)
    • 修复 1 —— 编辑后删除失效:结构性修改改为从文档读实时文本(liveSource),不再信任渲染时捕获的 self.source。单元格聚焦期间 widget 的 DOM 被刻意保留(eq() 恒为 true),那份副本在第一次编辑落盘后即过期,后续提交被静默拒绝
    • 修复 2 —— 表格吞掉下面那一行:表格节点在紧跟一行文字时会把那行也包进范围,widget 因而覆盖它(光标进不去、打字变成表格内容)。范围现在截回最后一个真正的表格行(判定为「行内含 |」而非「行首为 |」,因为 GFM 允许省略首尾管道符)
    • 修复 3 —— 连续输入时最后一次丢失:EditableTableWidget.destroy() 会清空待提交的编辑。每次提交都会重建 widget,于是上一次提交遗留的 destroy 会在用户仍在输入时把待提交编辑扔掉,blur 微任务随后扑空。现在销毁时不再清空;同时 liveSource 会拒绝一个已不是表格的位置,避免向错误范围写入
  • packages/plugin-toolbar: insertTable 的结尾换行随之恢复(该行此前因 widget 会吞掉它而移除)

Testing / 测试

  • pnpm test passes / 全绿 —— 949 passed | 1 skipped(68 files)
  • Affected packages build (pnpm build) / 受影响包构建通过
  • pnpm typecheck —— 0 errors
  • pnpm check:api —— 公共 API 快照校验通过
  • pnpm build:electron-demo + smoke:multi-window —— 通过

New / updated vitest cases / 新增或更新的 vitest 用例:

  • packages/plugin-slash:+34(手柄渲染、边界钳制、拖拽阈值、原子撤销、确认对齐、与 history 的组合、存储失败容错、FLIP 动画、prefers-reduced-motion)
  • packages/plugin-toolbar:+10(表格形状与钳制、单次撤销、换行分隔、光标落点、网格读数、emoji 插入、tooltip 定位)
  • packages/core:+7(行/列删除按钮与选中/拖拽互不干扰、自适应宽度、编辑后删除、连续编辑保留、表格范围不再覆盖下一行)

Manual UI check in electron-demo / electron-demo 手动验证:

  • 斜杠菜单拖拽:实时重排、松手提交、Esc 还原、拖出边界钳制、回车执行被拖拽项
  • 表格:工具栏插入 → 下方可输入;行/列删除按钮;编辑单元格后删除仍生效;连续编辑 3 格后强制重建,8/8 全部保留(修复前 8 次里丢 4 次)
  • emoji:分类网格、点击插入到光标处
  • 工具栏 tooltip:按钮下方居中、深色面板

一处已知限制:表格 widget 的连续编辑回归无法在 jsdom 中复现(需要 widget 跨提交存活,依赖真实焦点语义;jsdom 每次 flush 都会重建 widget 并把陈旧副本刷新掉)。该用例以 it.skip 保留并注明了原因,验证依赖 electron-demo。


Compliance / 合规自检

  • CLA signed —— 首次贡献者按 CLA 机器人提示签署
  • AI disclosure: the functional code in this PR was largely drafted by AI under my direction. I chose the approach, reviewed every change line by line, and can explain and defend any decision in it — including why each of the three table defects was fixed the way it was, and why the drag reordering deliberately avoids the HTML5 drag-and-drop API. Flagging this explicitly because GOVERNANCE.md §6.2 sets a high bar here, and I would rather be precise about where the work came from than vague.
  • New dependencies: 无新增运行时依赖。github.com 镜像仅用于本地 clone,未进入仓库。
  • No build artifacts committed / 未提交构建产物 —— git status 干净,dist/ 均在 .gitignore 内
  • No secrets / .env / personal vault data committed / 无敏感信息

AI 使用说明(透明披露):本 PR 的功能代码主要由 AI 在我主导下起草;方案由我选定,每一处改动我都逐行审阅过,可以解释并答辩其中任何决策。三个表格缺陷的根因,是我在 electron-demo 中用真实鼠标/键盘事件复现、再定位到具体代码路径后修复的,验证数据(如「修复前 8 次丢 4 次 / 修复后 8 次全保留」)为实测结果。


Checklist / 自检清单

  • Title follows Conventional Commits / 标题遵循 Conventional Commits
  • Public API changes update package README / types —— 新增导出已补类型;LivePreviewLabels 与 NexusLocale 各增一个 autoFitWidth 字段,均为可选/带默认值的增量变更
  • Touched live-preview-table.ts → walked through the 12 Table Widget rules in CLAUDE.md / 已核对 12 条表格规则 —— 特别是第 5 条(禁用 HTML5 Drag API)、第 6 条(grip 定位)、第 7 条(全部交互路径):新增按钮阻断 mousedown/click,列拖拽与 grip 选中均已回归验证
  • New capability / breaking change → OpenSpec proposal linked / 已附三份 OpenSpec proposal
  • Change aligns with project scope (GOVERNANCE.md §4) / 符合 GOVERNANCE.md §4 项目范围 —— 均为编辑器原语;未引入 AI/LLM 集成、未打包厂商 SDK、未加入通用 UI 组件库、未给 demo 增加产品级功能

Screenshots / Recordings · 截图或录屏 (UI changes)

1-斜杠菜单拖拽 2-表格选择器 3-emoji面板 4-行列表格按钮 5-工具栏tooltip

Commits: 21 commits, 28 files, +2579 −64.

Roadmap floatboatai#16 introduced automatic ordering by recency, but a user who wants
a stable personal layout has no way to express it: the menu is always either
registration order or recency order. The only lever today is pre-sorting the
slashCommands array at registration time, which cannot change at runtime and
cannot coexist with recency ordering.

Add an opt-in `reorderable` option that renders a grip handle on every item
and lets the user drag commands into place.

- New `command-order.ts` mirrors the `command-history.ts` storage contract:
  `true` for session-only, `{ storage, storageKey }` for host-injected
  persistence. No implicit global `localStorage` write.
- Manual placement is applied after the recency layer, so pinned commands win
  and unplaced commands keep the order history produced.
- Reordering uses custom mousedown/mousemove/mouseup. The HTML5 drag-and-drop
  API is deliberately avoided, per the repository's custom-drag convention.
- Drops are clamped to the rendered bounds, and `itemEls`, `visibleCommands`,
  the DOM, and the highlight index move together so `Enter` always confirms
  the command the user sees.
- Because the editor caps results before the menu renders, a write rewrites
  only the visible ids and preserves stored ids outside that window.

Fixing this surfaced a latent issue: item hover and click handlers captured
their index when the node was created, which is only valid while element
position never changes. A reorder invalidates that, so indices are now
resolved at event time.

Verified against real browser layout in the Electron demo (option enabled
temporarily, then reverted): handle hit area, live reorder, drop commit,
highlight follow-through, and `Enter` confirming the dropped command.
Rows jumped straight to their new slot on every reorder, which reads as a
glitch rather than as direct manipulation.

Animate the move with FLIP (measure, reorder, invert, play). Rows sit in
normal flow, so a reorder changes their layout position and a CSS transition
has nothing to interpolate — the transform is what gives the browser
something to ease. The held row follows the pointer instead of sliding, and
every inline style the gesture applies is cleared when it ends so hosts keep
control of the look.

Honour `prefers-reduced-motion` by skipping the slide entirely.

The pointer offset exposed a measurement bug: `getBoundingClientRect` reports
the transformed box, so the held row's own midpoint was skewed by its own
offset and drop targets resolved one slot late. Drop candidates are now
measured with that transform cleared and the offset reapplied afterwards.

Verified against real browser layout: moved rows carry the inversion, the
held row tracks the pointer, unmoved rows keep no inline style, and both
`Enter` confirmation and the drop index stay correct.
The add-slash-menu-reorder tasks predate the FLIP animation and still list
apps/electron-demo as out of scope, which the demo opt-in contradicts. Add
the animation tasks and move the demo from "out of scope" to affected code.
The library keeps reordering off by default, which left the demo unable to
show it at all. The demo exists to demonstrate engine capabilities, so opt
in there.

The handle also needs host styling: the package deliberately ships no
stylesheet, so an unstyled handle would have no hit area to grab.
`positionToolbarTooltip` writes `left` and `top` from the button's viewport
rect, but nothing ever positioned the element. Those writes were inert: the
tooltip stayed `position: static` and fell back into normal flow at the end of
the document, so hovering a toolbar button produced an invisible full-width box
far from the button instead of a label under it.

Set `position: fixed` — the coordinates are viewport-relative — and pull the box
back by half its own width, because `left` anchors the button's centre rather
than the tooltip's edge. Both are geometry the existing code already assumes;
colours and typography stay with the host.

The existing coverage only asserted `role` and `textContent`, which is why this
went unnoticed. It now also asserts that the anchor applies.
The package owns the geometry but ships no stylesheet, so the demo has to give
the tooltip its look — otherwise it renders as a transparent box with black
text once it is actually positioned.
The insert-link button drew a flat outlined capsule with a line through it.
At 18px it reads as an oval, not as a link, so the button had to be learned
rather than recognised — the one pictographic icon in the toolbar whose
shape did not name its action.

Replace it with a diagonal chain of two interlocking hooks.

The geometry is drawn from scratch to match the surrounding set: an 18-unit
box, 1.8 stroke, same visual weight as undo / redo. The hooks are laid out
upright and rotated, so the numbers stay readable, and the gaps between them
are sized against the stroke — a round cap adds 0.9 past each end, so
anything tighter than a 3.0 separation merges the two hooks back into the
single capsule this change is removing.
# Conflicts:
#	apps/electron-demo/src/renderer/style.css
Roadmap floatboatai#12 planned an emoji picker, table tools, and a colour picker. The
colour half shipped already, but the toolbar could not produce a table or an
emoji at all — the two block types every writer reaches for and neither of
which anyone wants to remember the syntax for.

Two independent changes, each with its own OpenSpec proposal under
`openspec/changes/`, landed together because they touch the same files:

Table (`add-toolbar-table-insert`):
- `insertTable(editor, rows, cols)` writes a GFM table through a single
  `replaceRange`, so one undo removes the whole thing.
- The header counts as the first picked row, matching the shape the picker
  draws — a `2 x 3` pick is a three-column table with one body row.
- A 6 x 6 size grid reports the hovered size as `N x M` and inserts on click.

Emoji (`add-toolbar-emoji-picker`):
- `insertEmoji(editor, emoji)` inserts at the caret through
  `replaceSelection`.
- A curated, category-grouped set in `emoji.ts`, exported as
  `EMOJI_CATEGORIES`. Deliberately not a full Unicode table: that is a large
  runtime asset, and adding one would put a licence review in front of the
  change.

Both pickers reuse the existing dropdown path (`DROPDOWN_IDS`,
`DROPDOWN_STYLES`, the outside-click handler, `closeDropdown`) instead of
introducing a second overlay mechanism, and both stay off the document unless
the user opens them. No new dependencies.
Two problems found by actually using the feature.

The table could not be removed. `insertTable` left the caret at the end of the
inserted block, which is the position pressed against the table's trailing
edge — the table renders as an atomic range and CM6 does not hold a caret
there, rewriting it to the document start. Backspace and Delete then had
nothing adjacent to act on and the table looked permanent. The block now always
ends with a newline so the caret lands on the line past the table, where one
backward delete removes it. The picker also hands focus back to the editor
before dispatching, since a dropdown mounted on `document.body` blurs it.

The size grid stopped at 6 x 6, which is short for a document table. It is now
10 x 10.
Removing a row or column meant knowing the right-click menu existed, or
selecting the row/column first and pressing Delete. Neither is discoverable,
and the table is the one block in the editor whose structure cannot be edited
from the text itself.

- Each column renders a trash button above its grip, and each data row one to
  the left of its grip. They appear with the grip on hover.
- The operations were already implemented for the context menu and the Delete
  key; these buttons are a second entry point to `deleteColumn` / `deleteRow`,
  so both paths stay in one implementation.
- The grips already own `mousedown` (drag to reorder) and `click` (select
  column). The buttons stop both, or pressing delete would drag or highlight
  the column instead — the regression that made this worth testing on all
  interaction paths.
- A column button is hidden when a single column remains, matching the context
  menu's existing guard.
- The trash glyph is drawn rather than typed as "×": a cross reads as
  "dismiss", which is the wrong promise for a control that rewrites the table.

Auto-fit width undoes a manual resize. It drops the remembered widths and
tears down the colgroup, fixed layout, and explicit width that
`applyColumnWidths` installed. It edits the DOM in place rather than
dispatching, because the source is unchanged and a no-op transaction would be
swallowed by the widget's `eq()`; the work therefore lives on the widget,
which is the only scope holding the mounted `<table>` and its width key.

`NexusLocale` and `LivePreviewLabels` both gain `autoFitWidth`. The locale
field is resolved through the explicit field-by-field mapping in `editor.ts`,
which is easy to miss when adding a label.
Deleting a row or column did nothing when a cell had just been edited. The
symptom looked intermittent only because it needed an edit first.

Two things stacked up:

The structural edit was computed from `this.source`, the text captured when
the widget was built. A focused cell keeps its text in the cell's DOM and
`eq()` deliberately keeps the widget alive through editing, so that copy
lags the document. `dispatch` validates its source against the document
before writing and returns in silence on a mismatch, so the delete was
dropped without a trace.

And even a correctly computed delete was discarded: while a cell is focused
its text is pending, and the cell's next sync wrote its own buffer back over
the structural change.

`mutateTable` now flushes pending table edits, reads the table's live text
from the document, and applies the line transform to that. Delete, add, and
non-drag move all go through it.

The drag path is deliberately left on `dispatch`: it captures a source
containing a cell edit that has not reached the document yet, so the widget's
copy is still current and one transaction commits both the cell text and the
move. Routing it through `mutateTable` broke exactly that, which two existing
drag tests caught.
Typing into a table cell and then moving the cursor away through a path that
skips the blur microtask leaves the edit pending in `dirtyRows` — the document
never learns about it, and the cell renders empty the next time anything
rebuilds the table. Reported as "输入 5,点击 table 之外的地方,5 消失".

The repro is deterministic: activate a cell, set its text, dispatch `input`,
then move the editor selection. No blur runs, so nothing flushes.

Checked and ruled out: the widget's `eq()` is never called here, so the text is
not lost to a DOM rebuild; and `dataset.source` is not stale, because the input
handler refreshes it on every keystroke.

Left as `it.skip` rather than deleted — it pins the failure down and is the
starting point for the real fix, which is choosing what commits a pending edit
when no blur arrives.
Consecutive edits in a table silently vanished once the first one landed.
Typing across a header row and then clicking away kept only the first cell;
the rest showed in the grid until anything rebuilt it from the document, and
then they were simply gone.

`syncDirtyRowsToDocument` compared the document against `self.source`, the
text captured when the widget was built, and gave up when they differed. While
a cell is focused the widget's DOM is deliberately preserved — `eq()` returns
true for the whole edit session — so that copy goes stale the moment the first
edit is written. Every later commit then failed the comparison and dropped its
rows without a word.

The commit now reads the table's live text from the document (the `liveSource`
helper added for the delete fix), uses it as the base for the dirty-row
snapshot, and replaces that range. The drag path keeps the old contract: it
commits a pending cell edit and the move in one transaction over the text the
widget was built from, which its two tests pin down.

Verified in the Electron demo: three consecutive header-cell edits survived a
forced rebuild after the change, where none of them did before.

No unit test: the failure needs the widget to survive across commits, which
requires real focus semantics — jsdom rebuilds on every flush and refreshes the
stale copy before it can be observed. The skipped test says so.
The caret now goes before the table, and the block only ends with a newline
when there is text to separate it from. Two separate reasons:

An empty trailing line is swallowed by the table widget's replacement range,
which runs past the table's last row. The caret cannot be put on that line at
all, and anything typed there renders as part of the table — the line is
unreachable, which is worse than not offering one.

And the position pressed against the table's trailing edge is not one CM6 will
hold a caret at: a caret sent there is rewritten to the document start, which
is what made a freshly inserted table look impossible to delete.

Both are covered by their tests.
Typing on the line directly below a table put the text inside the table, and
the line could not be reached with the caret afterwards.

A table node runs past its own rows when the following line has no blank line
between them — the parser folds that line into the node. The widget replaces
exactly the node's range, so the line ended up inside the widget: the caret
cannot be placed there, and anything typed on it is rendered as table content.
The range is now trimmed back to the last line that is actually a row —
"contains a pipe" rather than "starts with one", since GFM lets a row drop its
leading and trailing pipes.

With that fixed, `insertTable` goes back to always ending the block with a
newline. That trailing line is what the previous commit removed, because the
widget used to swallow it — the caret is back on it now, and typing there stays
in the document. Its tests follow.
…royed

The last edit of a run was lost. Typing across a row and then leaving the table
kept every cell except the one typed last, and it was gone the next time
anything rebuilt the table.

`EditableTableWidget.destroy()` cleared the pending-edit map. Committing an edit
rebuilds the widget, so the destroy left over from the previous commit ran while
the user was still typing in a cell: it threw that pending edit away, and the
blur microtask that should have written it back found the map already empty and
did nothing. The rows are now left in place on destroy.

A widget destroyed because its table is gone must not write into that position,
so `liveSource` now refuses a position whose line is not a table row — the
commit path bails instead of overwriting whatever moved into the gap.

Verified in the Electron demo: eight consecutive runs kept all three cell edits,
against four losses in eight before the change.
@CLAassistant

CLAassistant commented Sep 21, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@DC911360 DC911360 changed the title feat: toolbar table/emoji, slash menu reordering, and table fixes feat: toolbar table/emoji, slash menu reordering, and table fixes1-斜杠菜单拖拽.png 2-表格选择器.png 3-emoji面板.png 4-行列表格按钮.png 5-工具栏tooltip.png Sep 21, 2026
@DC911360 DC911360 changed the title feat: toolbar table/emoji, slash menu reordering, and table fixes1-斜杠菜单拖拽.png 2-表格选择器.png 3-emoji面板.png 4-行列表格按钮.png 5-工具栏tooltip.png feat: toolbar table/emoji, slash menu reordering, and table fixes Sep 21, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants