From 5e97e67ecdc5c4bcfddbebc6365789c394e4fd4e Mon Sep 17 00:00:00 2001 From: congzhou09 Date: Mon, 4 May 2026 02:33:09 +0800 Subject: [PATCH] fix(editor): prevent connector label from breaking after click + move in empty label editor (#14830) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Problem ●In edgeless mode, after clicking and moving in a connector's label editor, if the label editor has empty content at the end of the editing, the label editor for that connector can not be triggered again. ●The following video demonstrates this issue: https://github.com/user-attachments/assets/8d300720-5ed8-4f9c-90fa-fbf059417ff8 ### Root Cause **Direct cause** ●The `labelOffset` property is **stashed** at drag-start, but is **not properly popped** afterward. As a result, when `mountConnectorLabelEditor()` is called the second time (`packages/affine/gfx/connector/src/text/edgeless-connector-label-editor.ts`), `connector.labelOffset` returns `undefined` instead of the default value provided by the `@field` decorator. **Why moving after clicking incorrectly triggers a drag-start** ●The root issue lies in the interaction between click and drag event handling. Here's the actual flow: 1.`dispatcher.add('click', () => true)` is registered in `EdgelessConnectorLabelEditor`. 2.On pointer-down, both `ClickController` and `DragController` receive the event. 3.On pointer-up, `ClickController` fires a **synthetic click**. The handler from step 1 returns `true`, triggering `context.get('defaultState').event.stopPropagation()`. 4.This prevents the native pointer-up from bubbling to `DragController`. However, a subsequent pointer-move still causes `DragController` to **incorrectly synthesize a drag-start + drag-move**. **Fundamental root cause** ●The line `context.get('defaultState').event.stopPropagation()` in `UIEventDispatcher::run()` stops **both** synthetic and native event bubbling. It should only stop synthetic event propagation. ●The synthetic event bubbling stopping is already properly handled by the immediate `return` statement on the next line, because the runners are prepared in strict order (current → parent → grandparent → ... → global) by `UIEventDispatcher::_getEventScope()` and then **executed sequentially** in `UIEventDispatcher::run()`. ### Fix ●Since I cannot rule out that other (current or future) event handlers may rely on this native event bubbling stopping behavior, I chose not to remove the `context.get('defaultState').event.stopPropagation()` line completely. Instead, I added a new constant and now skip `stopPropagation()` **only** for the following synthetic events: ```ts const syntheticEventNames = new Set(['click', 'doubleClick', 'tripleClick']); ``` These currently represent all known synthetic click events triggered from pointer-up. ### After ●The video below shows the behavior after this fix. https://github.com/user-attachments/assets/65b8a3ce-0767-4d80-986b-8bc6081ddd4c --- blocksuite/framework/std/src/event/dispatcher.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/blocksuite/framework/std/src/event/dispatcher.ts b/blocksuite/framework/std/src/event/dispatcher.ts index 244ab4e49..bc08bc880 100644 --- a/blocksuite/framework/std/src/event/dispatcher.ts +++ b/blocksuite/framework/std/src/event/dispatcher.ts @@ -78,6 +78,8 @@ export type EventHandlerRunner = { blockId?: string; }; +const syntheticEventNames = new Set(['click', 'doubleClick', 'tripleClick']); + export class UIEventDispatcher extends LifeCycleWatcher { private static _activeDispatcher: UIEventDispatcher | null = null; @@ -435,7 +437,10 @@ export class UIEventDispatcher extends LifeCycleWatcher { const { fn } = runner; const result = fn(context); if (result) { - context.get('defaultState').event.stopPropagation(); + // Only stop propagation for non-synthetic events + if (!syntheticEventNames.has(name)) { + context.get('defaultState').event.stopPropagation(); + } return; } }