Conversation
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.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 缺陷。
Motivation / 背景与动机
#12高级 toolbar(emoji picker / 表格工具 / 颜色选择)·#9Widget API标准化(相关背景)·
#25E2E 测试(表格回归的动机)add-slash-menu-reorder·add-toolbar-table-insert·add-toolbar-emoji-pickerRoadmap
#12planned 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 }为宿主持久化,不隐式写 localStoragemenu-ui.ts:每个 item 前置拖拽手柄(内联 SVG 点阵),使用自写mousedown/mousemove/mouseup拖拽 —— 刻意不用 HTML5 Drag API(沿用仓库既有约定)itemEls、visibleCommands、DOM 与高亮索引同步移动,回车永远执行用户看到的那一条prefers-reduced-motion;被拖拽行跟随指针slashMenuLimit),因此写回时只重写可见 id,窗口外的 id 保持原有相对顺序apps/electron-demo: 打开该开关(库默认仍关闭),并补手柄样式2. Table & emoji insertion in the toolbar
packages/plugin-toolbar:insertTable(editor, rows, cols)—— 单事务写入 GFM 表格,一次 undo 撤掉整张表;rows含表头,与选择器画出的形状一致N x M读数,点击插入insertEmoji(editor, emoji)+ 精选 emoji 集合(4 类 50 个,EMOJI_CATEGORIES对外导出)iconTable/iconEmoji,并把link图标由「椭圆+横线」重绘为对角链条(原图标读不出「链接」的含义)DROPDOWN_IDS/DROPDOWN_STYLES/ 外部点击关闭 /closeDropdown)openspec/:add-toolbar-table-insert、add-toolbar-emoji-picker3. Table widget: row/column actions and three fixes
packages/core:mousedown与click—— grip 自身拥有拖拽(mousedown)与选中列(click),不阻断就会误触发。最后一列保留右键菜单既有的「不可删」保护applyColumnWidths装上的<colgroup>/fixed/ 显式宽度)liveSource),不再信任渲染时捕获的self.source。单元格聚焦期间 widget 的 DOM 被刻意保留(eq()恒为 true),那份副本在第一次编辑落盘后即过期,后续提交被静默拒绝|」而非「行首为|」,因为 GFM 允许省略首尾管道符)EditableTableWidget.destroy()会清空待提交的编辑。每次提交都会重建 widget,于是上一次提交遗留的 destroy 会在用户仍在输入时把待提交编辑扔掉,blur 微任务随后扑空。现在销毁时不再清空;同时liveSource会拒绝一个已不是表格的位置,避免向错误范围写入packages/plugin-toolbar:insertTable的结尾换行随之恢复(该行此前因 widget 会吞掉它而移除)Testing / 测试
pnpm testpasses / 全绿 —— 949 passed | 1 skipped(68 files)pnpm build) / 受影响包构建通过pnpm typecheck—— 0 errorspnpm 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还原、拖出边界钳制、回车执行被拖拽项Compliance / 合规自检
github.com镜像仅用于本地 clone,未进入仓库。git status干净,dist/均在.gitignore内.env/ personal vault data committed / 无敏感信息AI 使用说明(透明披露):本 PR 的功能代码主要由 AI 在我主导下起草;方案由我选定,每一处改动我都逐行审阅过,可以解释并答辩其中任何决策。三个表格缺陷的根因,是我在 electron-demo 中用真实鼠标/键盘事件复现、再定位到具体代码路径后修复的,验证数据(如「修复前 8 次丢 4 次 / 修复后 8 次全保留」)为实测结果。
Checklist / 自检清单
LivePreviewLabels与NexusLocale各增一个autoFitWidth字段,均为可选/带默认值的增量变更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 选中均已回归验证Screenshots / Recordings · 截图或录屏 (UI changes)
Commits: 21 commits, 28 files, +2579 −64.