Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: pressing space in button contenteditable #309

Merged
merged 4 commits into from
Dec 18, 2024
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
12 changes: 9 additions & 3 deletions src/visualBuilder/listeners/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import EventListenerHandlerParams from "./types";
import { VisualBuilder } from "..";
import handleBuilderInteraction from "./mouseClick";
import handleMouseHover, {
hideCustomCursor,
hideHoverOutline,
showCustomCursor,
} from "./mouseHover";
import EventListenerHandlerParams from "./types";

type AddEventListenersParams = Omit<
EventListenerHandlerParams,
Expand Down Expand Up @@ -76,10 +76,16 @@ export function removeEventListeners(params: RemoveEventListenersParams): void {
window.removeEventListener("mousemove", mousemoveHandler);
}
if (mouseleaveHandler) {
document.documentElement.removeEventListener("mouseleave", mouseleaveHandler);
document.documentElement.removeEventListener(
"mouseleave",
mouseleaveHandler
);
}
if (mouseenterHandler) {
document.documentElement.removeEventListener("mouseenter", mouseenterHandler);
document.documentElement.removeEventListener(
"mouseenter",
mouseenterHandler
);
}

eventListenersMap.clear();
Expand Down
46 changes: 46 additions & 0 deletions src/visualBuilder/utils/__test__/handleFieldMouseDown.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { MockInstance } from "vitest";
import { VISUAL_BUILDER_FIELD_TYPE_ATTRIBUTE_KEY } from "../constants";
import { handleFieldKeyDown } from "../handleFieldMouseDown";
import * as insertSpaceAtCursor from "../insertSpaceAtCursor";

describe("handle numeric field key down", () => {
let h1: HTMLHeadingElement;
Expand Down Expand Up @@ -106,3 +107,48 @@ describe("handle numeric field key down", () => {
expect(spiedPreventDefault).toHaveBeenCalledTimes(1);
});
});

describe("handle keydown in button contenteditable", () => {
let button: HTMLButtonElement | undefined;
let spiedPreventDefault: MockInstance<(e: []) => void> | undefined;
let spiedInsertSpaceAtCursor:
| MockInstance<(typeof insertSpaceAtCursor)["insertSpaceAtCursor"]>
| undefined;

test("should insert space in button content-editable", () => {
vi.spyOn(window, "getSelection").mockReturnValue({
// @ts-ignore
getRangeAt: (n: number) => ({
startOffset: 0,
endOffset: 0,
}),
});
spiedInsertSpaceAtCursor = vi.spyOn(
insertSpaceAtCursor,
"insertSpaceAtCursor"
);

button = document.createElement("button");
button.innerHTML = "Test";
button.setAttribute("contenteditable", "true");
button.setAttribute(
VISUAL_BUILDER_FIELD_TYPE_ATTRIBUTE_KEY,
"single_line"
);

button.addEventListener("keydown", (e) => {
spiedPreventDefault = vi.spyOn(e, "preventDefault");
handleFieldKeyDown(e);
});

const keyDownEvent = new KeyboardEvent("keydown", {
bubbles: true,
key: "Space",
code: "Space",
});
button.dispatchEvent(keyDownEvent);

expect(spiedPreventDefault).toHaveBeenCalledTimes(1);
expect(spiedInsertSpaceAtCursor).toHaveBeenCalledWith(button);
});
});
85 changes: 85 additions & 0 deletions src/visualBuilder/utils/__test__/insertSpaceAtCursor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { describe, it, expect, vi } from "vitest";
import { insertSpaceAtCursor } from "../insertSpaceAtCursor";
import { unicodeNonBreakingSpace } from "../constants";

describe("insertSpaceAtCursor", () => {
// Mock setup to simulate browser selection behavior
const setupMockSelection = (initialContent = "") => {
const element = document.createElement("div");
element.textContent = initialContent;

// Mock window.getSelection
const mockSelection = {
rangeCount: 1,
getRangeAt: vi.fn(),
removeAllRanges: vi.fn(),
addRange: vi.fn(),
};

// Mock Range object
const mockRange = {
deleteContents: vi.fn(),
insertNode: vi.fn(),
setStartAfter: vi.fn(),
setEndAfter: vi.fn(),
};

vi.spyOn(window, "getSelection").mockReturnValue(mockSelection as any);
mockSelection.getRangeAt.mockReturnValue(mockRange);

return { element, mockSelection, mockRange };
};

it("should insert a non-breaking space when selection exists", () => {
const { element, mockSelection, mockRange } =
setupMockSelection("Hello World");

insertSpaceAtCursor(element);

// Verify that space node was created and inserted
expect(mockRange.deleteContents).toHaveBeenCalled();
expect(mockRange.insertNode).toHaveBeenCalledWith(
expect.objectContaining({
nodeType: Node.TEXT_NODE,
textContent: unicodeNonBreakingSpace,
})
);
expect(mockRange.setStartAfter).toHaveBeenCalled();
expect(mockRange.setEndAfter).toHaveBeenCalled();
expect(mockSelection.removeAllRanges).toHaveBeenCalled();
expect(mockSelection.addRange).toHaveBeenCalled();
});

it("should do nothing if no selection exists", () => {
const { element, mockSelection } = setupMockSelection();

// Simulate no ranges
mockSelection.rangeCount = 0;

// Should handle this case without throwing
expect(() => insertSpaceAtCursor(element)).not.toThrow();
});

it("should replace existing selection with non-breaking space", () => {
const { element, mockRange } = setupMockSelection("Selected Text");

insertSpaceAtCursor(element);

// Verify that existing content is deleted before inserting space
expect(mockRange.deleteContents).toHaveBeenCalled();
});

// Edge case: Test with different types of elements
it.each(["button", "div", "span", "p", "textarea"])(
"should work with %s element",
(tagName) => {
const element = document.createElement(tagName);
const { mockRange } = setupMockSelection();

insertSpaceAtCursor(element);

// Basic verification that insertion process was attempted
expect(mockRange.insertNode).toHaveBeenCalled();
}
);
});
2 changes: 2 additions & 0 deletions src/visualBuilder/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,3 +46,5 @@ export const DEFAULT_MULTIPLE_FIELDS: FieldDataType[] = [
FieldDataType.GROUP,
FieldDataType.BLOCK,
];

export const unicodeNonBreakingSpace = "\u00A0";
25 changes: 21 additions & 4 deletions src/visualBuilder/utils/handleFieldMouseDown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,18 @@ import {
} from "./constants";
import { FieldDataType } from "./types/index.types";
import { VisualBuilderPostMessageEvents } from "./types/postMessage.types";
import { insertSpaceAtCursor } from "./insertSpaceAtCursor";

export function handleFieldInput(e: Event): void {
const event = e as InputEvent;
const targetElement = event.target as HTMLElement;
const fieldType = targetElement.getAttribute(
VISUAL_BUILDER_FIELD_TYPE_ATTRIBUTE_KEY
) as FieldDataType | null;
if (event.type === "input" && ALLOWED_INLINE_EDITABLE_FIELD.includes(fieldType as FieldDataType)) {
if (
event.type === "input" &&
ALLOWED_INLINE_EDITABLE_FIELD.includes(fieldType as FieldDataType)
) {
throttledFieldSync();
}
}
Expand All @@ -23,13 +27,13 @@ const throttledFieldSync = throttle(() => {
const visualBuilderContainer = document.querySelector(
".visual-builder__container"
) as HTMLElement;
if(!visualBuilderContainer) return;
if (!visualBuilderContainer) return;
sendFieldEvent({
visualBuilderContainer,
eventType: VisualBuilderPostMessageEvents.SYNC_FIELD,
})
});
} catch (error) {
console.error("Error in throttledFieldSync", error)
console.error("Error in throttledFieldSync", error);
}
}, 300);

Expand All @@ -40,13 +44,26 @@ export function handleFieldKeyDown(e: Event): void {
VISUAL_BUILDER_FIELD_TYPE_ATTRIBUTE_KEY
) as FieldDataType | null;

if (targetElement.tagName === "BUTTON") {
handleKeyDownOnButton(event);
}
if (fieldType === FieldDataType.NUMBER) {
handleNumericFieldKeyDown(event);
} else if (fieldType === FieldDataType.SINGLELINE) {
handleSingleLineFieldKeyDown(event);
}
}

// spaces do not work inside a button content-editable
// this adds a space and moves the cursor ahead, the
// button press event is also prevented
function handleKeyDownOnButton(e: KeyboardEvent) {
if (e.code === "Space" && e.target) {
e.preventDefault();
insertSpaceAtCursor(e.target as HTMLElement);
}
}

function handleSingleLineFieldKeyDown(e: KeyboardEvent) {
if (e.code === "Enter") {
e.preventDefault();
Expand Down
28 changes: 28 additions & 0 deletions src/visualBuilder/utils/insertSpaceAtCursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { unicodeNonBreakingSpace } from "./constants";

export function insertSpaceAtCursor(element: HTMLElement) {
// Check if the browser supports modern selection API
const selection = window.getSelection();

// Ensure there's a valid selection
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);

// Create a text node with a space
const spaceNode = document.createTextNode(unicodeNonBreakingSpace);

// Delete any selected content first
range.deleteContents();

// Insert the space node
range.insertNode(spaceNode);

// Move cursor after the inserted space
range.setStartAfter(spaceNode);
range.setEndAfter(spaceNode);

// Update the selection
selection.removeAllRanges();
selection.addRange(range);
}
}
Loading