fix: HTML Embed Code editor inside Dialog Content looses focus (#3020)

## Description

closes #2998

Also fixes name edits etc.
The same issue with radix as we had in the past, focus lock on any
mutation inside dialog aquires focus.
And we have no good way to prevent programmatic focus inside iframe.



## Steps for reproduction

1. click button
2. expect xyz

## 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.example`
and the `builder/env-check.js` if mandatory
This commit is contained in:
Ivan Starkov authored and GitHub committed 2024-03-20 22:42:44 +03:00
1 parent ddb7e068ab
commit 3c189aff97
7 files changed
+109 -74

No files matched your search

@@ -24,6 +24,7 @@ import {
$selectedInstance,
$registeredComponentMetas,
$dragAndDropState,
$inspectorLastInputTime,
} from "~/shared/nano-states";
import { NavigatorTree } from "~/builder/shared/navigator-tree";
import type { Settings } from "~/builder/shared/client-settings";
@@ -70,6 +71,11 @@ const contentStyle = {
const $isDragging = computed([$dragAndDropState], (state) => state.isDragging);
const handleInspectorInput = () => {
// Notify canvas of input changes, related to setInert on iframe
$inspectorLastInputTime.set(Date.now());
};
export const Inspector = ({ navigatorLayout }: InspectorProps) => {
const selectedInstance = useStore($selectedInstance);
const tabsRef = useRef<HTMLDivElement>(null);
@@ -110,57 +116,63 @@ export const Inspector = ({ navigatorLayout }: InspectorProps) => {
>
<FloatingPanelProvider container={tabsRef}>
<BindingPopoverProvider value={{ containerRef: tabsRef }}>
<PanelTabs
ref={tabsRef}
value={availableTabs.includes(tab) ? tab : availableTabs[0]}
onValueChange={setTab}
asChild
>
<Flex direction="column">
<PanelTabsList>
{isStyleTabVisible && (
<div style={{ display: "contents" }} onInput={handleInspectorInput}>
<PanelTabs
ref={tabsRef}
value={availableTabs.includes(tab) ? tab : availableTabs[0]}
onValueChange={setTab}
asChild
>
<Flex direction="column">
<PanelTabsList>
{isStyleTabVisible && (
<Tooltip
variant="wrapped"
content="The Style panel allows manipulation of CSS visually."
>
<div>
<PanelTabsTrigger value="style">Style</PanelTabsTrigger>
</div>
</Tooltip>
)}
<Tooltip
variant="wrapped"
content="The Style panel allows manipulation of CSS visually."
content="The Settings panel allows for customizing component properties and HTML attributes."
>
<div>
<PanelTabsTrigger value="style">Style</PanelTabsTrigger>
<PanelTabsTrigger value="settings">
Settings
</PanelTabsTrigger>
</div>
</Tooltip>
)}
<Tooltip
variant="wrapped"
content="The Settings panel allows for customizing component properties and HTML attributes."
</PanelTabsList>
<Separator />
<PanelTabsContent
value="style"
css={contentStyle}
tabIndex={-1}
>
<div>
<PanelTabsTrigger value="settings">
Settings
</PanelTabsTrigger>
</div>
</Tooltip>
</PanelTabsList>
<Separator />
<PanelTabsContent value="style" css={contentStyle} tabIndex={-1}>
<InstanceInfo instance={selectedInstance} />
<StylePanel selectedInstance={selectedInstance} />
</PanelTabsContent>
<PanelTabsContent
value="settings"
css={contentStyle}
tabIndex={-1}
>
<ScrollArea>
<InstanceInfo instance={selectedInstance} />
<SettingsPanelContainer
key={
selectedInstance.id /* Re-render when instance changes */
}
selectedInstance={selectedInstance}
/>
</ScrollArea>
</PanelTabsContent>
</Flex>
</PanelTabs>
<StylePanel selectedInstance={selectedInstance} />
</PanelTabsContent>
<PanelTabsContent
value="settings"
css={contentStyle}
tabIndex={-1}
>
<ScrollArea>
<InstanceInfo instance={selectedInstance} />
<SettingsPanelContainer
key={
selectedInstance.id /* Re-render when instance changes */
}
selectedInstance={selectedInstance}
/>
</ScrollArea>
</PanelTabsContent>
</Flex>
</PanelTabs>
</div>
</BindingPopoverProvider>
</FloatingPanelProvider>
</EnhancedTooltipProvider>
+2
View File
@@ -56,6 +56,7 @@ import { subscribeCommands } from "~/canvas/shared/commands";
import { updateCollaborativeInstanceRect } from "./collaborative-instance";
import { $params } from "./stores";
import { useScrollNewInstanceIntoView } from "./shared/use-scroll-new-instance-into-view";
import { subscribeInspectorEdits } from "./inspector-edits";
registerContainers();
@@ -129,6 +130,7 @@ const DesignMode = ({ params }: { params: Params }) => {
useEffect(updateCollaborativeInstanceRect, []);
useEffect(subscribeInstanceSelection, []);
useEffect(subscribeInstanceHovering, []);
useEffect(subscribeInspectorEdits, []);
return null;
};
@@ -0,0 +1,12 @@
import { $inspectorLastInputTime, $props } from "~/shared/nano-states";
import { setInert } from "./shared/inert";
export const subscribeInspectorEdits = () => {
const unsubscribeInputTime = $inspectorLastInputTime.listen(setInert);
const unsubscribeProps = $props.listen(setInert);
return () => {
unsubscribeInputTime();
unsubscribeProps();
};
};
+33
View File
@@ -0,0 +1,33 @@
let resetTimeoutHandle: number | undefined = undefined;
const resetAutoDisposeInert = () => {
document.body.removeAttribute("inert");
clearTimeout(resetTimeoutHandle);
resetTimeoutHandle = undefined;
};
// 1000 ms is a reasonable time for the preview to reset.
// Anyway should never happen after user has finished preview changes (can happen during preview changes)
const AUTO_DISPOSE_INERT_TIMEOUT = 1000;
// A brief delay to ensure mutation observers within the focus scope are activated by the preview changes.
const DISPOSE_INERT_TIMEOUT = 300;
const setAutoDisposeInert = (timeout: number) => {
document.body.setAttribute("inert", "true");
// To prevent a completely non-interactive canvas due to edge cases,
// make sure to clean up preview changes if preview styles fail to reset correctly.
clearTimeout(resetTimeoutHandle);
resetTimeoutHandle = window.setTimeout(resetAutoDisposeInert, timeout);
};
/**
* Controls (e.g., radix focus scope) may inadvertently shift focus from inputs.
* Example: When the user modifies styles or content in the settings panel, the use of a mutation observer with Radix causes the focus to shift to the Radix dialog.
* Currently, there's no way to block focus shifts inside iframes (see https:*github.com/w3c/webappsec-permissions-policy/issues/273 for future updates).
* Workaround: use the `inert` attribute on iframe body to prevent focus changes.
*/
export const setInert = () => setAutoDisposeInert(AUTO_DISPOSE_INERT_TIMEOUT);
export const resetInert = () => setAutoDisposeInert(DISPOSE_INERT_TIMEOUT);
+3 -31
View File
@@ -34,6 +34,7 @@ import {
compareMedia,
} from "@webstudio-is/css-engine";
import { $ephemeralStyles } from "../stores";
import { resetInert, setInert } from "./inert";
const userSheet = createRegularStyleSheet({ name: "user-styles" });
const helpersSheet = createRegularStyleSheet({ name: "helpers" });
@@ -118,40 +119,11 @@ const subscribeEphemeralStyle = (params: Params) => {
// track custom properties added on previous ephemeral styles change
const addedCustomProperties = new Set<string>();
let timeoutHandle: number | undefined = undefined;
const resetInert = () => {
document.body.removeAttribute("inert");
clearTimeout(timeoutHandle);
timeoutHandle = undefined;
};
// 1000 ms is a reasonable time for the preview to reset.
// Anyway should never happen after user has finished preview changes (can happen during preview changes)
const AUTO_DISPOSE_INERT_TIMEOUT = 1000;
// A brief delay to ensure mutation observers within the focus scope are activated by the preview changes.
const DISPOSE_INERT_TIMEOUT = 300;
const setAutoDisposeInert = (timeout: number) => {
document.body.setAttribute("inert", "true");
// To prevent a completely non-interactive canvas due to edge cases,
// make sure to clean up preview changes if preview styles fail to reset correctly.
clearTimeout(timeoutHandle);
timeoutHandle = window.setTimeout(resetInert, timeout);
};
return $ephemeralStyles.subscribe((ephemeralStyles) => {
// Controls (e.g., radix focus scope) may inadvertently shift focus from inputs.
// Currently, there's no way to block focus shifts inside iframes (see https://github.com/w3c/webappsec-permissions-policy/issues/273 for future updates).
// Workaround: use the `inert` attribute on iframe body to prevent focus changes.
if (ephemeralStyles.length > 0) {
setAutoDisposeInert(AUTO_DISPOSE_INERT_TIMEOUT);
setInert();
} else {
// Delayed reset same as set with smaller timeout
setAutoDisposeInert(DISPOSE_INERT_TIMEOUT);
resetInert();
}
// track custom properties not set on this change
@@ -343,3 +343,5 @@ export const $dragAndDropState = atom<DragAndDropState>({
});
export const $marketplaceProduct = atom<undefined | MarketplaceProduct>();
export const $inspectorLastInputTime = atom<number>(0);
@@ -38,6 +38,7 @@ import {
$resources,
$resourceValues,
$marketplaceProduct,
$inspectorLastInputTime,
} from "~/shared/nano-states";
import { $ephemeralStyles } from "~/canvas/stores";
@@ -118,6 +119,7 @@ export const registerContainers = () => {
clientStores.set("dragAndDropState", $dragAndDropState);
clientStores.set("ephemeralStyles", $ephemeralStyles);
clientStores.set("selectedInstanceStates", $selectedInstanceStates);
clientStores.set("inspectorLastInputTime", $inspectorLastInputTime);
for (const [name, store] of $synchronizedBreakpoints) {
clientStores.set(name, store);