Skip to content

text: Add a foundation for a block-based WYSIWYG editor - #3049

Open
EnoroF wants to merge 2 commits into
longbridge:mainfrom
EnoroF:codex/markdown-editor
Open

text: Add a foundation for a block-based WYSIWYG editor#3049
EnoroF wants to merge 2 commits into
longbridge:mainfrom
EnoroF:codex/markdown-editor

Conversation

@EnoroF

@EnoroF EnoroF commented Sep 12, 2026

Copy link
Copy Markdown
  • Add the foundation for a block-based WYSIWYG Markdown editor.
  • Reuse existing Markdown parsing, rendering, selection, and undo history.
  • Support basic formatting, IME, list indentation, task lists, and read-only mode.
  • Add an editor example using the original Markdown demo content and plugins.
  • Keep images, tables, code blocks, and custom elements visible but not directly editable.
  • Validated on Windows: 165 text tests and Clippy passed.

@huacnlee huacnlee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for putting this together — it is a real, working prototype, and the rendering seam is genuinely nice: InlineInteraction + NodeContext::paragraph_interaction is a small change that lets an editor reuse the whole Markdown parser, renderer and plugin pipeline. That part I would take as-is.

The editor itself, though, is a long way from being a foundation we can build on, and I don't think it should land in gpui-base in its current shape. A block-based WYSIWYG editor is something we would consider including if it comes with a solid architectural design underneath; right now the model, the position system and the public API are all "make it move" code, and everything above them would have to be rewritten once they change. Running examples/markdown-editor shows this directly: many regions cannot be selected at all, and code blocks, images and tables cannot be edited, deleted or moved.

Design-level issues

  1. Positions are pointer addresses. Paragraph::editor_id() returns Arc::as_ptr(&self.state). A Position is therefore not serializable, does not survive a re-parse, cannot be validated, and is subject to address reuse once history is trimmed or set_source runs. Every model operation .unwrap()s on lookup — passing a stale Position to Document::replace panics at model.rs:296. A foundation needs a stable, comparable, checkable position type that can address any block, not only text paragraphs.

  2. Only text paragraphs exist in the model. paragraph() filters the tree down to Paragraph/Heading without images or custom nodes. Code blocks, tables, images and custom elements have no position, so they get no caret, no selection, and cannot be removed — select-all + type on `# T\n\n- a\n\n```rs\ncode\n```` leaves the code block behind. Supporting them is not an extra branch; it requires the position/selection model to cover the whole block tree.

  3. Serialization breaks on ordinary typing. Each insert creates a new InlineNode and nothing normalizes adjacent nodes with equal marks. Typing cd in bold after **ab** produces four nodes and source() returns **ab****c****d**, which is not valid CommonMark emphasis.

  4. O(P²) per frame. InlineEditing::paint calls selection_in for every paragraph; each call runs paragraphs() (full-tree walk, allocates) and ordered() (allocates again). render also re-renders every block each frame and computes selected(first, last) for aria_value. With the 1000-line fixture this will not scale.

  5. Model mutation inside paint. InlineInteraction::paint does owner.update(cx, …) to rebuild layouts, flip active and drive the blink cursor. Layout state is cleared in render, refilled during paint and read during event handling, with no explicit contract between the three.

  6. The public API is a command list, not a model. Position/selection are pub(super), so an application cannot ask where the caret is, what is selected, or which block type it is in (no toolbar state). format(a, b, bold: bool) chooses between bold and italic with a bool; strikethrough, code, link and highlight have no path. MarkdownEditorEvent::Change carries nothing, and there is no selection-changed event. There is no transaction/command abstraction — undo snapshots the entire Document per edit() call, so every keystroke is its own undo step.

  7. Structural commands only see the top level. make_list and move_block search self.blocks directly; toggle_list on a paragraph inside a blockquote or a nested list item silently does nothing. The Markdown shortcut prefixes (# ..### , - , 1. , [ ] ) are string-matched inside insert().

  8. Naming and layering. MarkdownEditorState collides with the existing EditorState (docs/ARCHITECTURE.md, "Input, Textarea, and Editor Architecture"). Base paints the 1px caret, the composition underline, the \u{200b} placeholder and the IBeam cursor with no injection point, unlike InputEditorStyle; TextViewDefaults fields were opened to pub(super) only so the editor can read them.

Suggested path

  • Split out and land separately: InlineInteraction / paragraph_interaction, the list-item indentation fix in to_markdown, and the examples/fixtures/markdown_plugins.rs extraction. These are independent, small and safe.
  • For the editor, settle the model before the implementation: a block-level Position that can address every node, a validated selection type, inline editing primitives with normalization, a mark API keyed on TextMark, events with payloads, queryable selection/block state, and a style injection seam for the caret/selection. With that in place the rendering seam and most of the IME/undo/keybinding scaffolding in mod.rs carry over; model.rs would need a rewrite.

Happy to review a design write-up first if you want to go that route.


中文

感谢这个 PR,它是一个真正能跑的原型,而且渲染层的接缝设计得很好:InlineInteraction + NodeContext::paragraph_interaction 改动很小,却让编辑器完整复用了 Markdown 的解析、渲染和插件流水线。这部分我可以原样接收。

但编辑器本体距离一个可以往上盖的 Foundation 还比较远,目前的形态不适合进入 gpui-base。block-based WYSIWYG 编辑器这个方向,如果底下有扎实的架构设计,我们是可以考虑纳入的;现在的模型、位置系统和公开 API 都还是"先让它动起来"的写法,一旦这三块改动,上面的东西都要跟着重写。实际运行 examples/markdown-editor 也能直接看到:很多区域无法选中,code block、图片、表格既不能编辑,也不能删除或移动。

设计层面的问题

  1. Position 建立在指针地址上。 Paragraph::editor_id() 返回的是 Arc::as_ptr(&self.state)。于是 Position 不可序列化、不能跨 re-parse 存活、无法校验,历史被裁剪或 set_source 之后地址还可能被复用。模型里所有按位置查找的操作都是 .unwrap():给 Document::replace 传一个失效的 Position 会直接 panic 在 model.rs:296。Foundation 需要的是一个稳定、可比较、可校验、且能指向任意 block 的位置类型,而不只是文本段落。

  2. 模型里只有文本段落。 paragraph() 把树过滤成不含图片和 custom 节点的 Paragraph/Heading。code block、表格、图片、自定义元素在模型里没有位置,所以没有光标、没有选区、也删不掉:对 `# T\n\n- a\n\n```rs\ncode\n```` 全选后输入,code block 会留下来。支持它们不是多加一个分支的事,需要位置/选区模型覆盖整棵 block 树。

  3. 正常输入就会破坏序列化。 每次插入都新建一个 InlineNode,没有任何步骤合并相邻同 mark 的节点。在 **ab** 后面加粗输入 cd,得到四个节点,source() 输出 **ab****c****d**,这不是合法的 CommonMark 强调语法。

  4. 每帧 O(P²)。 InlineEditing::paint 对每个段落调用 selection_in,它内部每次都跑 paragraphs()(遍历整棵树并分配)和 ordered()(再分配一次)。render 也是每帧重渲染全部 block,还为 aria_value 计算一遍 selected(first, last)。用 1000 行的 fixture 就撑不住。

  5. 在 paint 阶段修改模型。 InlineInteraction::paint 里通过 owner.update(cx, …) 重建 layouts、翻转 active、驱动 blink cursor。布局状态在 render 里清空、在 paint 里填回、在事件处理里读取,三者之间没有明确的契约。

  6. 公开 API 是命令清单,不是模型。 Position/selection 是 pub(super),应用层无法查询光标在哪、选中了什么、当前处于哪种 block(做不了工具栏状态)。format(a, b, bold: bool) 用一个 bool 在 bold 和 italic 之间选,strikethrough、code、link、highlight 没有入口。MarkdownEditorEvent::Change 不带任何 payload,也没有 selection 变化事件。没有 transaction/command 抽象,undo 是每次 edit() 调用整份 Document 快照,敲一个字就是一个 undo step。

  7. 结构操作只看顶层。 make_listmove_block 直接在 self.blocks 里查找;对 blockquote 或嵌套列表项里的段落调 toggle_list 会静默无效。Markdown 快捷前缀(# ..### - 1. [ ] )是硬编码在 insert() 里的字符串匹配。

  8. 命名和分层。 MarkdownEditorState 与已有的 EditorStatedocs/ARCHITECTURE.md 的 "Input, Textarea, and Editor Architecture" 一节)概念冲突。Base 层直接绘制 1px 光标、composition 下划线、\u{200b} 占位和 IBeam 指针,没有像 InputEditorStyle 那样的注入点;TextViewDefaults 的字段改成 pub(super) 只是为了让编辑器能读到。

建议的路径

  • 先拆出来单独合并:InlineInteraction / paragraph_interactionto_markdown 里列表项缩进的修复、examples/fixtures/markdown_plugins.rs 的抽取。这三块彼此独立、改动小、没有风险。
  • 编辑器本体先定模型再写实现:能指向每一个节点的 block 级 Position、可校验的 selection 类型、带 normalize 的 inline 编辑原语、以 TextMark 为参数的 mark API、带 payload 的事件、可查询的 selection/block 状态,以及光标/选区的样式注入接缝。这些定下来之后,渲染接缝和 mod.rs 里 IME、undo、键位的大部分脚手架都能沿用,model.rs 需要重写。

如果你愿意走这条路,可以先写一份设计稿,我很乐意先 review 设计。

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