Files
webstudio/apps/builder/app/canvas/interceptor.ts
T
Ivan Starkov 38d49f9f69 feat: Add native select control (#3616)
## Description

rename of #3609

## New

- Native Select

## Changes

- Improved detection algorithm to include invisible elements (like
`option` or `display:none`).
- Added interceptor to handle pointer down events, preventing select
from opening.
- Enhanced element selection on canvas to support `display:none` and
option selection.
- Fixed prop types generation (previously, the value property was
broken).
- Memory props has effect on canvas only
- Recreate component on Canvas in case of defaultValue has changed


## Steps for reproduction

Add `Select` see it works

## Code Review

- [ ] hi @kof, I need you to do
  - conceptual review (architecture, feature-correctness)
  - detailed review (read every line)
  - test it on preview

## Before requesting a review

- [ ] made a self-review
- [ ] added inline comments where things may be not obvious (the "why",
not "what")

## Before merging

- [ ] tested locally and on preview environment (preview dev login:
5de6)
- [ ] updated [test
cases](https://github.com/webstudio-is/webstudio/blob/main/apps/builder/docs/test-cases.md)
document
- [ ] added tests
- [ ] if any new env variables are added, added them to `.env` file
2024-06-26 07:07:37 +03:00

168 lines
5.2 KiB
TypeScript

import { getPagePath, type System } from "@webstudio-is/sdk";
import {
compilePathnamePattern,
matchPathnamePattern,
tokenizePathnamePattern,
} from "~/builder/shared/url-pattern";
import {
$dataSourceVariables,
$isPreviewMode,
$pages,
$selectedPage,
updateSystem,
} from "~/shared/nano-states";
import { savePathInHistory, switchPage } from "~/shared/pages";
const isAbsoluteUrl = (href: string) => {
try {
new URL(href);
return true;
} catch {
return false;
}
};
const getSelectedPagePathname = () => {
const page = $selectedPage.get();
const dataSourceVariables = $dataSourceVariables.get();
if (page) {
const system = dataSourceVariables.get(page.systemDataSourceId) as
| undefined
| System;
const tokens = tokenizePathnamePattern(page.path);
return compilePathnamePattern(tokens, system?.params ?? {});
}
};
const switchPageAndUpdateSystem = (href: string, formData?: FormData) => {
const pages = $pages.get();
if (pages === undefined) {
return;
}
// preserve pathname when not specified in href/action
if (href === "" || href.startsWith("?") || href.startsWith("#")) {
const pathname = getSelectedPagePathname();
if (pathname) {
href = pathname + href;
}
}
const pageHref = new URL(href, "https://any-valid.url");
for (const page of [pages.homePage, ...pages.pages]) {
const pagePath = getPagePath(page.id, pages);
const params = matchPathnamePattern(pagePath, pageHref.pathname);
if (params) {
// populate search params with form data values if available
if (formData) {
for (const [key, value] of formData.entries()) {
pageHref.searchParams.set(key, value.toString());
}
}
const search = Object.fromEntries(pageHref.searchParams);
switchPage(page.id, pageHref.hash);
updateSystem(page, { params, search });
savePathInHistory(page.id, pageHref.pathname);
break;
}
}
};
export const subscribeInterceptedEvents = () => {
const handleClick = (event: MouseEvent) => {
if (
event.target instanceof HTMLElement ||
event.target instanceof SVGElement
) {
const a = event.target.closest("a");
if (a) {
event.preventDefault();
if ($isPreviewMode.get()) {
// use attribute instead of a.href to get raw unresolved value
const href = a.getAttribute("href") ?? "";
if (isAbsoluteUrl(href)) {
window.open(href, "_blank");
} else {
switchPageAndUpdateSystem(href);
}
}
}
// prevent invoking submit with buttons in canvas mode
// because form with prevented submit still invokes validation
if (event.target.closest("button") && $isPreviewMode.get() === false) {
event.preventDefault();
}
}
};
const handlePointerDown = (event: PointerEvent) => {
if (false === event.target instanceof HTMLElement) {
return;
}
if (event.target.closest("select") && $isPreviewMode.get() === false) {
event.preventDefault();
}
};
const handleSubmit = (event: SubmitEvent) => {
if ($isPreviewMode.get()) {
const form =
event.target instanceof HTMLFormElement ? event.target : undefined;
if (form === undefined) {
return;
}
// use attribute instead of form.action to get raw unresolved value
// https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#dom-fs-action
const action = form.getAttribute("action") ?? "";
// lower case just for safety
const method = form.method.toLowerCase();
if (method === "get" && isAbsoluteUrl(action) === false) {
switchPageAndUpdateSystem(action, new FormData(form));
}
}
// prevent submitting the form when clicking a button type submit
event.preventDefault();
};
const handleKeydown = (event: KeyboardEvent) => {
if ($isPreviewMode.get()) {
return;
}
// prevent typing in inputs only in canvas mode
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
event.preventDefault();
}
};
// Note: Event handlers behave unexpectedly when used inside a dialog component.
// In Dialogs, React intercepts and processes events before they reach our handlers.
// To ensure consistent behavior across all components, we're using event capturing.
// This allows us to intercept events before React gets a chance to handle them.
document.documentElement.addEventListener("click", handleClick, {
capture: true,
});
document.documentElement.addEventListener("submit", handleSubmit, {
capture: true,
});
document.documentElement.addEventListener("keydown", handleKeydown);
document.documentElement.addEventListener("pointerdown", handlePointerDown);
return () => {
document.documentElement.removeEventListener(
"pointerdown",
handlePointerDown
);
document.documentElement.removeEventListener("click", handleClick, {
capture: true,
});
document.documentElement.removeEventListener("submit", handleSubmit, {
capture: true,
});
document.documentElement.removeEventListener("keydown", handleKeydown);
};
};