Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@making-sense/antlr-editor",
"version": "2.6.0",
"version": "2.7.0",
"description": "ANTLR Typescript editor",
"repository": {
"type": "git",
Expand Down
78 changes: 67 additions & 11 deletions src/Editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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" &&
Expand Down Expand Up @@ -71,6 +80,7 @@ type EditorProps = {
shortcuts: Record<string, () => void>;
FooterComponent?: FC<{ cursor: CursorType }>;
displayFooter: boolean;
onSelectionChange?: (selection: EditorSelection) => void;
};

const Editor = ({
Expand All @@ -87,7 +97,8 @@ const Editor = ({
options,
shortcuts,
FooterComponent,
displayFooter = true
displayFooter = true,
onSelectionChange
}: EditorProps) => {
const editorRef = useRef<any>(null);
const monacoRef = useRef<any>(null);
Expand All @@ -103,6 +114,20 @@ const Editor = ({

// Cleanup function to properly dispose of Monaco resources
const subscriptionsRef = useRef<IDisposable[]>([]);
const selectionNotifierRef = useRef<SelectionChangeNotifier | null>(null);

useEffect(() => {
selectionNotifierRef.current = onSelectionChange
? createSelectionChangeNotifier(onSelectionChange)
: null;
}, [onSelectionChange]);

const reportSelection = useCallback((editor: Parameters<typeof buildMonacoSelection>[0]) => {
const notifier = selectionNotifierRef.current;
if (!notifier) return;
const { payload, hasSelection } = buildMonacoSelection(editor);
notifier.notify(payload, hasSelection);
}, []);

const cleanupMonaco = useCallback(() => {
subscriptionsRef.current.forEach(disposable => {
Expand Down Expand Up @@ -320,6 +345,7 @@ const Editor = ({
...prev,
selectionLength: length || 0
}));
reportSelection(editor);
})
);

Expand Down Expand Up @@ -374,7 +400,7 @@ const Editor = ({
})
);
},
[shortcuts]
[shortcuts, reportSelection]
);

const parseContent = useCallback(
Expand Down Expand Up @@ -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;
Expand All @@ -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%",
Expand Down
38 changes: 38 additions & 0 deletions src/__tests__/Editor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<Editor {...defaultProps} onSelectionChange={onSelectionChange} />);

expect(onSelectionChange).toHaveBeenCalledTimes(1);
expect(onSelectionChange).toHaveBeenCalledWith({
text: "",
startLine: 0,
startColumn: 0
});
});

it("calls onSelectionChange when textarea selection changes", () => {
const onSelectionChange = vi.fn();
render(<Editor {...defaultProps} script="hello world" onSelectionChange={onSelectionChange} />);

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(<Editor {...defaultProps} onSelectionChange={onSelectionChange} />);

const textarea = screen.getByTestId("monaco-editor-mock") as HTMLTextAreaElement;
fireEvent.select(textarea);

expect(onSelectionChange).toHaveBeenCalledTimes(1);
});

it("handles empty script", () => {
render(<Editor {...defaultProps} script="" />);

Expand Down
23 changes: 23 additions & 0 deletions src/__tests__/advanced/editor-props.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
130 changes: 130 additions & 0 deletions src/__tests__/utils/selection.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
5 changes: 5 additions & 0 deletions src/stories/VtlEditor20.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 }) => (
<div
style={{
Expand Down
7 changes: 6 additions & 1 deletion src/stories/VtlEditor21.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,19 @@ const variables = {
age: { type: VariableType.INTEGER, role: VariableRole.MEASURE }
};

const onSelectionChange = (selection: { text: string; startLine: number; startColumn: number }) => {
console.log("Editor selection:", selection);
};

export const Enriched = {
args: {
initialRule: "start",
variables,
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"] },
Expand Down
Loading
Loading