diff --git a/packages/core/src/lib/extmarks.test.ts b/packages/core/src/lib/extmarks.test.ts index 60d5d08f4..8634c40ae 100644 --- a/packages/core/src/lib/extmarks.test.ts +++ b/packages/core/src/lib/extmarks.test.ts @@ -2357,6 +2357,24 @@ Press ESC to return to main menu.` const type60Marks = extmarks.getAllForTypeId(60) expect(type60Marks.length).toBe(1) }) + + it("should move an extmark to the updated typeId", async () => { + await setup("Hello World") + + const id = extmarks.create({ + start: 0, + end: 5, + typeId: 10, + }) + + expect(extmarks.update(id, { typeId: 20 })).toBe(true) + expect(extmarks.getAllForTypeId(10)).toHaveLength(0) + + const marks = extmarks.getAllForTypeId(20) + expect(marks).toHaveLength(1) + expect(marks[0]?.id).toBe(id) + expect(extmarks.get(id)?.typeId).toBe(20) + }) }) describe("Undo/Redo with Extmarks", () => { diff --git a/packages/core/src/lib/extmarks.ts b/packages/core/src/lib/extmarks.ts index fc77247bf..e0474c3c0 100644 --- a/packages/core/src/lib/extmarks.ts +++ b/packages/core/src/lib/extmarks.ts @@ -1,6 +1,8 @@ import type { EditBuffer } from "../edit-buffer.js" import type { EditorView } from "../editor-view.js" +import type { MouseEvent } from "../renderer.js" import { ExtmarksHistory, type ExtmarksSnapshot } from "./extmarks-history.js" +import type { MouseEventType } from "./parse.mouse.js" export interface Extmark { id: number @@ -11,6 +13,9 @@ export interface Extmark { priority?: number data?: any typeId: number + onClick?: (extmarkId: number, event: MouseEvent) => void + onMouseDown?: (extmarkId: number, event: MouseEvent) => void + onMouseUp?: (extmarkId: number, event: MouseEvent) => void } export interface ExtmarkOptions { @@ -22,6 +27,9 @@ export interface ExtmarkOptions { data?: any typeId?: number metadata?: any + onClick?: (extmarkId: number, event: MouseEvent) => void + onMouseDown?: (extmarkId: number, event: MouseEvent) => void + onMouseUp?: (extmarkId: number, event: MouseEvent) => void } /** @@ -651,6 +659,9 @@ export class ExtmarksController { priority: options.priority, data: options.data, typeId, + onClick: options.onClick, + onMouseDown: options.onMouseDown, + onMouseUp: options.onMouseUp, } this.extmarks.set(id, extmark) @@ -703,6 +714,40 @@ export class ExtmarksController { return Array.from(this.extmarks.values()).filter((e) => offset >= e.start && offset < e.end) } + public update(id: number, options: Partial): boolean { + if (this.destroyed) { + throw new Error("ExtmarksController is destroyed") + } + + const extmark = this.extmarks.get(id) + if (!extmark) return false + + if (options.start !== undefined) extmark.start = options.start + if (options.end !== undefined) extmark.end = options.end + if (options.virtual !== undefined) extmark.virtual = options.virtual + if (options.styleId !== undefined) extmark.styleId = options.styleId + if (options.priority !== undefined) extmark.priority = options.priority + if (options.data !== undefined) extmark.data = options.data + if (options.typeId !== undefined && options.typeId !== extmark.typeId) { + this.extmarksByTypeId.get(extmark.typeId)?.delete(id) + extmark.typeId = options.typeId + if (!this.extmarksByTypeId.has(extmark.typeId)) { + this.extmarksByTypeId.set(extmark.typeId, new Set()) + } + this.extmarksByTypeId.get(extmark.typeId)!.add(id) + } + if (options.onClick !== undefined) extmark.onClick = options.onClick + if (options.onMouseDown !== undefined) extmark.onMouseDown = options.onMouseDown + if (options.onMouseUp !== undefined) extmark.onMouseUp = options.onMouseUp + + if (options.metadata !== undefined) { + this.metadata.set(id, options.metadata) + } + + this.updateHighlights() + return true + } + public getAllForTypeId(typeId: number): Extmark[] { if (this.destroyed) return [] const ids = this.extmarksByTypeId.get(typeId) @@ -806,6 +851,32 @@ export class ExtmarksController { return this.metadata.get(extmarkId) } + public handleMouseEvent(offset: number, event: MouseEvent): void { + if (this.destroyed) return + + const extmarks = this.getAtOffset(offset) + for (const extmark of extmarks) { + const handler = this.getEventHandler(extmark, event.type) + if (handler) { + handler(extmark.id, event) + } + } + } + + private getEventHandler( + extmark: Extmark, + eventType: MouseEventType, + ): ((extmarkId: number, event: MouseEvent) => void) | undefined { + switch (eventType) { + case "down": + return extmark.onMouseDown + case "up": + return extmark.onMouseUp ?? extmark.onClick + default: + return undefined + } + } + public destroy(): void { if (this.destroyed) return diff --git a/packages/core/src/renderables/EditBufferRenderable.ts b/packages/core/src/renderables/EditBufferRenderable.ts index a156a53a0..2be3c6ff4 100644 --- a/packages/core/src/renderables/EditBufferRenderable.ts +++ b/packages/core/src/renderables/EditBufferRenderable.ts @@ -6,6 +6,7 @@ import { RGBA, parseColor } from "../lib/RGBA.js" import type { RenderContext, Highlight, CursorStyleOptions, LineInfoProvider, LineInfo } from "../types.js" import type { OptimizedBuffer } from "../buffer.js" import { MeasureMode } from "yoga-layout" +import type { MouseEvent } from "../renderer.js" import type { SyntaxStyle } from "../syntax-style.js" export interface CursorChangeEvent { @@ -62,6 +63,7 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf private _autoScrollAccumulator: number = 0 private _scrollSpeed: number = 16 private _keyboardSelectionActive: boolean = false + private _mouseOffset: number | undefined public readonly editBuffer: EditBuffer public readonly editorView: EditorView @@ -352,13 +354,34 @@ export abstract class EditBufferRenderable extends Renderable implements LineInf this._scrollSpeed = Math.max(0, value) } - protected override onMouseEvent(event: any): void { + protected override onMouseEvent(event: MouseEvent): void { if (event.type === "scroll") { this.handleScroll(event) + return + } + + if (event.type === "down") { + this._mouseOffset = this.cursorOffset + } + + if (event.isDragging) { + if (event.type === "up") { + this._mouseOffset = undefined + } + return + } + + if (event.type !== "down" && event.type !== "up") return + if (!this.editorView.extmarks) return + + this.editorView.extmarks.handleMouseEvent(this._mouseOffset ?? this.cursorOffset, event) + + if (event.type === "up") { + this._mouseOffset = undefined } } - protected handleScroll(event: any): void { + protected handleScroll(event: MouseEvent): void { if (!event.scroll) return const { direction, delta } = event.scroll diff --git a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts index 9bc35f890..76cd97056 100644 --- a/packages/core/src/renderables/__tests__/Textarea.selection.test.ts +++ b/packages/core/src/renderables/__tests__/Textarea.selection.test.ts @@ -189,6 +189,34 @@ describe("Textarea - Selection Tests", () => { expect(editor.getSelectedText()).toBe("World") }) + it("should not fire extmark click handlers during drag selection", async () => { + const { textarea: editor } = await createTextareaRenderable(currentRenderer, renderOnce, { + initialValue: "Hello World", + width: 40, + height: 10, + selectable: true, + }) + + let count = 0 + editor.extmarks.create({ + start: 0, + end: 5, + onClick: () => { + count += 1 + }, + onMouseUp: () => { + count += 1 + }, + }) + + await currentMouse.drag(editor.x, editor.y, editor.x + 5, editor.y) + await renderOnce() + + expect(editor.hasSelection()).toBe(true) + expect(editor.getSelectedText()).toBe("Hello") + expect(count).toBe(0) + }) + it("should render selection properly when drawing to buffer", async () => { const buffer = OptimizedBuffer.create(80, 24, "wcwidth")