text: Add a foundation for a block-based WYSIWYG editor - #3049
Conversation
There was a problem hiding this comment.
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
-
Positions are pointer addresses.
Paragraph::editor_id()returnsArc::as_ptr(&self.state). APositionis therefore not serializable, does not survive a re-parse, cannot be validated, and is subject to address reuse once history is trimmed orset_sourceruns. Every model operation.unwrap()s on lookup — passing a stalePositiontoDocument::replacepanics atmodel.rs:296. A foundation needs a stable, comparable, checkable position type that can address any block, not only text paragraphs. -
Only text paragraphs exist in the model.
paragraph()filters the tree down toParagraph/Headingwithout 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. -
Serialization breaks on ordinary typing. Each insert creates a new
InlineNodeand nothing normalizes adjacent nodes with equal marks. Typingcdin bold after**ab**produces four nodes andsource()returns**ab****c****d**, which is not valid CommonMark emphasis. -
O(P²) per frame.
InlineEditing::paintcallsselection_infor every paragraph; each call runsparagraphs()(full-tree walk, allocates) andordered()(allocates again).renderalso re-renders every block each frame and computesselected(first, last)foraria_value. With the 1000-line fixture this will not scale. -
Model mutation inside paint.
InlineInteraction::paintdoesowner.update(cx, …)to rebuildlayouts, flipactiveand drive the blink cursor. Layout state is cleared inrender, refilled during paint and read during event handling, with no explicit contract between the three. -
The public API is a command list, not a model.
Position/selection arepub(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::Changecarries nothing, and there is no selection-changed event. There is no transaction/command abstraction — undo snapshots the entireDocumentperedit()call, so every keystroke is its own undo step. -
Structural commands only see the top level.
make_listandmove_blocksearchself.blocksdirectly;toggle_liston a paragraph inside a blockquote or a nested list item silently does nothing. The Markdown shortcut prefixes (#..###,-,1.,[ ]) are string-matched insideinsert(). -
Naming and layering.
MarkdownEditorStatecollides with the existingEditorState(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, unlikeInputEditorStyle;TextViewDefaultsfields were opened topub(super)only so the editor can read them.
Suggested path
- Split out and land separately:
InlineInteraction/paragraph_interaction, the list-item indentation fix into_markdown, and theexamples/fixtures/markdown_plugins.rsextraction. These are independent, small and safe. - For the editor, settle the model before the implementation: a block-level
Positionthat can address every node, a validated selection type, inline editing primitives with normalization, a mark API keyed onTextMark, 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 inmod.rscarry over;model.rswould 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、图片、表格既不能编辑,也不能删除或移动。
设计层面的问题
-
Position 建立在指针地址上。
Paragraph::editor_id()返回的是Arc::as_ptr(&self.state)。于是Position不可序列化、不能跨 re-parse 存活、无法校验,历史被裁剪或set_source之后地址还可能被复用。模型里所有按位置查找的操作都是.unwrap():给Document::replace传一个失效的Position会直接 panic 在model.rs:296。Foundation 需要的是一个稳定、可比较、可校验、且能指向任意 block 的位置类型,而不只是文本段落。 -
模型里只有文本段落。
paragraph()把树过滤成不含图片和 custom 节点的Paragraph/Heading。code block、表格、图片、自定义元素在模型里没有位置,所以没有光标、没有选区、也删不掉:对 `# T\n\n- a\n\n```rs\ncode\n```` 全选后输入,code block 会留下来。支持它们不是多加一个分支的事,需要位置/选区模型覆盖整棵 block 树。 -
正常输入就会破坏序列化。 每次插入都新建一个
InlineNode,没有任何步骤合并相邻同 mark 的节点。在**ab**后面加粗输入cd,得到四个节点,source()输出**ab****c****d**,这不是合法的 CommonMark 强调语法。 -
每帧 O(P²)。
InlineEditing::paint对每个段落调用selection_in,它内部每次都跑paragraphs()(遍历整棵树并分配)和ordered()(再分配一次)。render也是每帧重渲染全部 block,还为aria_value计算一遍selected(first, last)。用 1000 行的 fixture 就撑不住。 -
在 paint 阶段修改模型。
InlineInteraction::paint里通过owner.update(cx, …)重建layouts、翻转active、驱动 blink cursor。布局状态在render里清空、在 paint 里填回、在事件处理里读取,三者之间没有明确的契约。 -
公开 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。 -
结构操作只看顶层。
make_list和move_block直接在self.blocks里查找;对 blockquote 或嵌套列表项里的段落调toggle_list会静默无效。Markdown 快捷前缀(#..###、-、1.、[ ])是硬编码在insert()里的字符串匹配。 -
命名和分层。
MarkdownEditorState与已有的EditorState(docs/ARCHITECTURE.md的 "Input, Textarea, and Editor Architecture" 一节)概念冲突。Base 层直接绘制 1px 光标、composition 下划线、\u{200b}占位和 IBeam 指针,没有像InputEditorStyle那样的注入点;TextViewDefaults的字段改成pub(super)只是为了让编辑器能读到。
建议的路径
- 先拆出来单独合并:
InlineInteraction/paragraph_interaction、to_markdown里列表项缩进的修复、examples/fixtures/markdown_plugins.rs的抽取。这三块彼此独立、改动小、没有风险。 - 编辑器本体先定模型再写实现:能指向每一个节点的 block 级
Position、可校验的 selection 类型、带 normalize 的 inline 编辑原语、以TextMark为参数的 mark API、带 payload 的事件、可查询的 selection/block 状态,以及光标/选区的样式注入接缝。这些定下来之后,渲染接缝和mod.rs里 IME、undo、键位的大部分脚手架都能沿用,model.rs需要重写。
如果你愿意走这条路,可以先写一份设计稿,我很乐意先 review 设计。
Uh oh!
There was an error while loading. Please reload this page.