diff --git a/package.json b/package.json index 3bae9ea..3580e6f 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@making-sense/antlr-editor", - "version": "2.6.0", + "version": "2.7.0", "description": "ANTLR Typescript editor", "repository": { "type": "git", diff --git a/src/Editor.tsx b/src/Editor.tsx index 3f59fc5..643fa5d 100644 --- a/src/Editor.tsx +++ b/src/Editor.tsx @@ -5,8 +5,17 @@ import { Tools, Error, Variables } from "./model"; import { buildVariables, buildUniqueVariables } from "./utils/variables"; import EditorFooter from "./EditorFooter"; import { shouldSuppressMonacoError } from "./utils/monaco-errors"; +import { + buildMonacoSelection, + buildTextareaSelection, + createSelectionChangeNotifier, + type EditorSelection, + type SelectionChangeNotifier +} from "./utils/selection"; import { IDisposable } from "monaco-editor"; +export type { EditorSelection }; + // Check if we're in a test environment const isTestEnvironment = typeof globalThis !== "undefined" && @@ -71,6 +80,7 @@ type EditorProps = { shortcuts: Record void>; FooterComponent?: FC<{ cursor: CursorType }>; displayFooter: boolean; + onSelectionChange?: (selection: EditorSelection) => void; }; const Editor = ({ @@ -87,7 +97,8 @@ const Editor = ({ options, shortcuts, FooterComponent, - displayFooter = true + displayFooter = true, + onSelectionChange }: EditorProps) => { const editorRef = useRef(null); const monacoRef = useRef(null); @@ -103,6 +114,20 @@ const Editor = ({ // Cleanup function to properly dispose of Monaco resources const subscriptionsRef = useRef([]); + const selectionNotifierRef = useRef(null); + + useEffect(() => { + selectionNotifierRef.current = onSelectionChange + ? createSelectionChangeNotifier(onSelectionChange) + : null; + }, [onSelectionChange]); + + const reportSelection = useCallback((editor: Parameters[0]) => { + const notifier = selectionNotifierRef.current; + if (!notifier) return; + const { payload, hasSelection } = buildMonacoSelection(editor); + notifier.notify(payload, hasSelection); + }, []); const cleanupMonaco = useCallback(() => { subscriptionsRef.current.forEach(disposable => { @@ -320,6 +345,7 @@ const Editor = ({ ...prev, selectionLength: length || 0 })); + reportSelection(editor); }) ); @@ -374,7 +400,7 @@ const Editor = ({ }) ); }, - [shortcuts] + [shortcuts, reportSelection] ); const parseContent = useCallback( @@ -452,6 +478,32 @@ const Editor = ({ const isDark = theme.includes("dark"); + useEffect(() => { + if (!ready || !isTestEnvironment || !onSelectionChange) return; + const { payload, hasSelection } = buildTextareaSelection(script || "", 0, 0); + selectionNotifierRef.current?.notify(payload, hasSelection); + }, [ready, onSelectionChange]); + + const handleTextareaSelect = useCallback( + (value: string, selectionStart: number, selectionEnd: number) => { + const lines = value.substring(0, selectionStart).split("\n"); + setCursor({ + line: lines.length, + column: lines[lines.length - 1].length + 1, + selectionLength: Math.abs(selectionEnd - selectionStart) + }); + const notifier = selectionNotifierRef.current; + if (!notifier) return; + const { payload, hasSelection } = buildTextareaSelection( + value, + selectionStart, + selectionEnd + ); + notifier.notify(payload, hasSelection); + }, + [] + ); + if (!ready) return null; const bannerHeight = displayFooter ? 22 : 0; @@ -466,15 +518,19 @@ const Editor = ({ value={script || ""} onChange={e => { setScript?.(e.target.value); - // Simulate cursor position - const textarea = e.target; - const cursorPos = textarea.selectionStart; - const lines = textarea.value.substring(0, cursorPos).split("\n"); - setCursor({ - line: lines.length, - column: lines[lines.length - 1].length + 1, - selectionLength: 0 - }); + handleTextareaSelect( + e.target.value, + e.target.selectionStart, + e.target.selectionEnd + ); + }} + onSelect={e => { + const textarea = e.currentTarget; + handleTextareaSelect( + textarea.value, + textarea.selectionStart, + textarea.selectionEnd + ); }} style={{ width: "100%", diff --git a/src/__tests__/Editor.test.tsx b/src/__tests__/Editor.test.tsx index 03b419c..d425014 100644 --- a/src/__tests__/Editor.test.tsx +++ b/src/__tests__/Editor.test.tsx @@ -196,6 +196,44 @@ describe("Editor", () => { expect(screen.getByTestId("monaco-editor-mock")).toBeDefined(); }); + it("calls onSelectionChange once with empty selection on mount", () => { + const onSelectionChange = vi.fn(); + render(); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenCalledWith({ + text: "", + startLine: 0, + startColumn: 0 + }); + }); + + it("calls onSelectionChange when textarea selection changes", () => { + const onSelectionChange = vi.fn(); + render(); + + const textarea = screen.getByTestId("monaco-editor-mock") as HTMLTextAreaElement; + onSelectionChange.mockClear(); + textarea.setSelectionRange(0, 5); + fireEvent.select(textarea); + + expect(onSelectionChange).toHaveBeenCalledWith({ + text: "hello", + startLine: 1, + startColumn: 1 + }); + }); + + it("does not call onSelectionChange again when selection stays empty", () => { + const onSelectionChange = vi.fn(); + render(); + + const textarea = screen.getByTestId("monaco-editor-mock") as HTMLTextAreaElement; + fireEvent.select(textarea); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + }); + it("handles empty script", () => { render(); diff --git a/src/__tests__/advanced/editor-props.test.ts b/src/__tests__/advanced/editor-props.test.ts index e4c550e..7d4228e 100644 --- a/src/__tests__/advanced/editor-props.test.ts +++ b/src/__tests__/advanced/editor-props.test.ts @@ -220,6 +220,29 @@ describe("Editor Props", () => { expect(typeof props.FooterComponent).toBe("function"); }); + + it("should handle onSelectionChange callback", () => { + const onSelectionChange = (selection: { + text: string; + startLine: number; + startColumn: number; + }) => console.log(selection); + + const props = { + onSelectionChange, + tools: { + id: "vtl", + initialRule: "start", + grammar: "", + Lexer: class {}, + Parser: class {} + }, + shortcuts: {}, + displayFooter: true + }; + + expect(typeof props.onSelectionChange).toBe("function"); + }); }); describe("Default Values", () => { diff --git a/src/__tests__/utils/selection.test.ts b/src/__tests__/utils/selection.test.ts new file mode 100644 index 0000000..b89f4d1 --- /dev/null +++ b/src/__tests__/utils/selection.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, vi } from "vitest"; +import { + EMPTY_EDITOR_SELECTION, + buildMonacoSelection, + buildTextareaSelection, + createSelectionChangeNotifier, + offsetToLineColumn +} from "../../utils/selection"; + +describe("selection utils", () => { + describe("offsetToLineColumn", () => { + it("returns line 1 column 1 at offset 0", () => { + expect(offsetToLineColumn("hello", 0)).toEqual({ line: 1, column: 1 }); + }); + + it("accounts for newlines", () => { + expect(offsetToLineColumn("aa\nbb\ncc", 5)).toEqual({ line: 2, column: 3 }); + }); + }); + + describe("buildMonacoSelection", () => { + it("returns empty when selection is empty", () => { + const editor = { + getSelection: () => ({ + isEmpty: () => true, + startLineNumber: 2, + startColumn: 3, + endLineNumber: 2, + endColumn: 5 + }), + getModel: () => ({ + getValueInRange: () => "ab" + }) + }; + + expect(buildMonacoSelection(editor)).toEqual({ + payload: EMPTY_EDITOR_SELECTION, + hasSelection: false + }); + }); + + it("returns text and start position when selection is non-empty", () => { + const range = { + isEmpty: () => false, + startLineNumber: 2, + startColumn: 4, + endLineNumber: 3, + endColumn: 1 + }; + const editor = { + getSelection: () => range, + getModel: () => ({ + getValueInRange: (r: typeof range) => (r === range ? "selected" : "") + }) + }; + + expect(buildMonacoSelection(editor)).toEqual({ + payload: { text: "selected", startLine: 2, startColumn: 4 }, + hasSelection: true + }); + }); + }); + + describe("buildTextareaSelection", () => { + it("returns empty when start equals end", () => { + expect(buildTextareaSelection("abc", 1, 1)).toEqual({ + payload: EMPTY_EDITOR_SELECTION, + hasSelection: false + }); + }); + + it("returns selected text and start line/column", () => { + const value = "line1\nline2"; + expect(buildTextareaSelection(value, 6, 11)).toEqual({ + payload: { text: "line2", startLine: 2, startColumn: 1 }, + hasSelection: true + }); + }); + }); + + describe("createSelectionChangeNotifier", () => { + it("calls once with empty selection on first notify without selection", () => { + const onSelectionChange = vi.fn(); + const { notify } = createSelectionChangeNotifier(onSelectionChange); + + notify(EMPTY_EDITOR_SELECTION, false); + notify(EMPTY_EDITOR_SELECTION, false); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(onSelectionChange).toHaveBeenCalledWith(EMPTY_EDITOR_SELECTION); + }); + + it("calls on each non-empty selection", () => { + const onSelectionChange = vi.fn(); + const { notify } = createSelectionChangeNotifier(onSelectionChange); + + const first = { text: "a", startLine: 1, startColumn: 1 }; + const second = { text: "bc", startLine: 1, startColumn: 2 }; + + notify(first, true); + notify(second, true); + + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenNthCalledWith(1, first); + expect(onSelectionChange).toHaveBeenNthCalledWith(2, second); + }); + + it("calls empty once when clearing a previous selection", () => { + const onSelectionChange = vi.fn(); + const { notify } = createSelectionChangeNotifier(onSelectionChange); + + notify({ text: "x", startLine: 1, startColumn: 1 }, true); + notify(EMPTY_EDITOR_SELECTION, false); + notify(EMPTY_EDITOR_SELECTION, false); + + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(onSelectionChange).toHaveBeenLastCalledWith(EMPTY_EDITOR_SELECTION); + }); + + it("does not call again when moving cursor without selection after init", () => { + const onSelectionChange = vi.fn(); + const { notify } = createSelectionChangeNotifier(onSelectionChange); + + notify(EMPTY_EDITOR_SELECTION, false); + notify(EMPTY_EDITOR_SELECTION, false); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/index.ts b/src/index.ts index 4c8d4f1..f446511 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ // Opt-in patch: host apps decide if they need global listeners. export { default as AntlrEditor, CursorType } from "./Editor"; +export type { EditorSelection } from "./utils/selection"; export { cleanupProviders } from "./utils/providers"; export { applyMonacoPatch } from "./monaco-patch"; diff --git a/src/stories/VtlEditor20.stories.tsx b/src/stories/VtlEditor20.stories.tsx index ead3858..5482fb4 100644 --- a/src/stories/VtlEditor20.stories.tsx +++ b/src/stories/VtlEditor20.stories.tsx @@ -65,6 +65,10 @@ const shortcuts = { } }; +const onSelectionChange = (selection: { text: string; startLine: number; startColumn: number }) => { + console.log("Editor selection:", selection); +}; + export const Enriched = { args: { initialRule: "start", @@ -74,6 +78,7 @@ export const Enriched = { "https://raw.githubusercontent.com/Making-Sense-Info/ANTLR-Editor/gh-pages/samples/variablesInputFile2.json" ], shortcuts, + onSelectionChange, FooterComponent: ({ cursor }: { cursor: CursorType }) => (
{ + console.log("Editor selection:", selection); +}; + export const Enriched = { args: { initialRule: "start", @@ -81,7 +85,8 @@ export const Enriched = { variablesInputURLs: [ "https://raw.githubusercontent.com/Making-Sense-Info/ANTLR-Editor/gh-pages/samples/variablesInputFile1.json", "https://raw.githubusercontent.com/Making-Sense-Info/ANTLR-Editor/gh-pages/samples/variablesInputFile2.json" - ] + ], + onSelectionChange }, argTypes: { initialRule: { control: "select", options: ["start", "expr"] }, diff --git a/src/utils/selection.ts b/src/utils/selection.ts new file mode 100644 index 0000000..a33e24c --- /dev/null +++ b/src/utils/selection.ts @@ -0,0 +1,112 @@ +export type EditorSelection = { + text: string; + startLine: number; + startColumn: number; +}; + +export const EMPTY_EDITOR_SELECTION: EditorSelection = { + text: "", + startLine: 0, + startColumn: 0 +}; + +export type MonacoSelectionLike = { + isEmpty: () => boolean; + startLineNumber: number; + startColumn: number; + endLineNumber: number; + endColumn: number; +}; + +export type MonacoEditorSelectionSource = { + getSelection: () => MonacoSelectionLike | null; + getModel: () => { + getValueInRange: (range: MonacoSelectionLike) => string; + } | null; +}; + +export function offsetToLineColumn(text: string, offset: number): { line: number; column: number } { + const before = text.substring(0, offset); + const lines = before.split("\n"); + return { + line: lines.length, + column: lines[lines.length - 1].length + 1 + }; +} + +export function buildMonacoSelection(editor: MonacoEditorSelectionSource): { + payload: EditorSelection; + hasSelection: boolean; +} { + const selection = editor.getSelection(); + const model = editor.getModel(); + + if (!selection || !model || selection.isEmpty()) { + return { payload: EMPTY_EDITOR_SELECTION, hasSelection: false }; + } + + return { + payload: { + text: model.getValueInRange(selection), + startLine: selection.startLineNumber, + startColumn: selection.startColumn + }, + hasSelection: true + }; +} + +export function buildTextareaSelection( + value: string, + selectionStart: number, + selectionEnd: number +): { payload: EditorSelection; hasSelection: boolean } { + if (selectionStart === selectionEnd) { + return { payload: EMPTY_EDITOR_SELECTION, hasSelection: false }; + } + + const start = Math.min(selectionStart, selectionEnd); + const end = Math.max(selectionStart, selectionEnd); + const { line, column } = offsetToLineColumn(value, start); + + return { + payload: { + text: value.substring(start, end), + startLine: line, + startColumn: column + }, + hasSelection: true + }; +} + +/** + * Notifies on non-empty selection changes. Calls once with empty selection on init, + * and again when the user clears a previous selection. + */ +export function createSelectionChangeNotifier(onSelectionChange: (selection: EditorSelection) => void) { + let initialized = false; + let hadSelection = false; + + const notify = (payload: EditorSelection, hasSelection: boolean) => { + if (hasSelection) { + onSelectionChange(payload); + hadSelection = true; + initialized = true; + return; + } + + if (!initialized) { + onSelectionChange(EMPTY_EDITOR_SELECTION); + initialized = true; + return; + } + + if (hadSelection) { + onSelectionChange(EMPTY_EDITOR_SELECTION); + hadSelection = false; + } + }; + + return { notify }; +} + +export type SelectionChangeNotifier = ReturnType;