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

feat: Context menu for advanced style panel #4876

Merged
merged 14 commits into from
Feb 15, 2025
Merged
Show file tree
Hide file tree
Changes from 5 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
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
type RefObject,
} from "react";
import { useStore } from "@nanostores/react";
import { computed } from "nanostores";
import { matchSorter } from "match-sorter";
import { PlusIcon } from "@webstudio-is/icons";
import {
Expand Down Expand Up @@ -66,24 +65,16 @@ import {
} from "../../shared/use-style-data";
import {
$availableVariables,
$matchingBreakpoints,
getDefinedStyles,
useComputedStyleDecl,
useComputedStyles,
} from "../../shared/model";
import { getDots } from "../../shared/style-section";
import { PropertyInfo } from "../../property-label";
import { sections } from "../sections";
import { ColorPopover } from "../../shared/color-picker";
import {
$registeredComponentMetas,
$styles,
$styleSourceSelections,
} from "~/shared/nano-states";
import { useClientSupports } from "~/shared/client-supports";
import { $selectedInstancePath } from "~/shared/awareness";
import { $settings } from "~/builder/shared/client-settings";
import { composeEventHandlers } from "~/shared/event-utils";
import { CopyPasteMenu, propertyContainerAttribute } from "./copy-paste-menu";
import { $advancedStyles } from "./stores";

// Only here to keep the same section module interface
export const properties = [];
Expand Down Expand Up @@ -495,71 +486,6 @@ const AdvancedPropertyValue = ({
);
};

const initialProperties = new Set<StyleProperty>([
"cursor",
"mixBlendMode",
"opacity",
"pointerEvents",
"userSelect",
]);

const $advancedProperties = computed(
[
// prevent showing properties inherited from root
// to not bloat advanced panel
$selectedInstancePath,
$registeredComponentMetas,
$styleSourceSelections,
$matchingBreakpoints,
$styles,
$settings,
],
(
instancePath,
metas,
styleSourceSelections,
matchingBreakpoints,
styles,
settings
) => {
if (instancePath === undefined) {
return [];
}
const definedStyles = getDefinedStyles({
instancePath,
metas,
matchingBreakpoints,
styleSourceSelections,
styles,
});
// All properties used by the panels except the advanced panel
const baseProperties = new Set<StyleProperty>([]);
for (const { properties } of sections.values()) {
for (const property of properties) {
baseProperties.add(property);
}
}
const advancedProperties = new Set<StyleProperty>();
for (const { property, listed } of definedStyles) {
if (baseProperties.has(property) === false) {
// When property is listed, it was added from advanced panel.
// If we are in advanced mode, we show them all.
if (listed || settings.stylePanelMode === "advanced") {
advancedProperties.add(property);
}
}
}
// In advanced mode we assume user knows the properties they need, so we don't need to show these.
// @todo we need to find a better place for them in any case
if (settings.stylePanelMode !== "advanced") {
for (const property of initialProperties) {
advancedProperties.add(property);
}
}
return Array.from(advancedProperties);
}
);

/**
* The Advanced section in the Style Panel on </> Global Root has performance issues.
* To fix this, we skip rendering properties not visible in the viewport using the contentvisibilityautostatechange event,
Expand Down Expand Up @@ -631,6 +557,7 @@ const AdvancedProperty = memo(
wrap="wrap"
align="center"
justify="start"
{...{ [propertyContainerAttribute]: property }}
>
{isVisible && (
<>
Expand Down Expand Up @@ -662,7 +589,7 @@ const AdvancedProperty = memo(

export const Section = () => {
const [isAdding, setIsAdding] = useState(false);
const advancedProperties = useStore($advancedProperties);
const advancedStyles = useStore($advancedStyles);
const [recentProperties, setRecentProperties] = useState<StyleProperty[]>([]);
const addPropertyInputRef = useRef<HTMLInputElement>(null);
const recentValueInputRef = useRef<HTMLInputElement>(null);
Expand All @@ -672,6 +599,10 @@ export const Section = () => {
const containerRef = useRef<HTMLDivElement>(null);
const [minHeight, setMinHeight] = useState<number>(0);

const advancedProperties = Array.from(
advancedStyles.keys()
) as Array<StyleProperty>;

const currentProperties = searchProperties ?? advancedProperties;

const showRecentProperties =
Expand All @@ -681,6 +612,14 @@ export const Section = () => {
setMinHeight(containerRef.current?.getBoundingClientRect().height ?? 0);
};

const handleInsertStyles = (cssText: string) => {
const styles = insertStyles(cssText);
const insertedProperties = styles.map(({ property }) => property);
setRecentProperties(
Array.from(new Set([...recentProperties, ...insertedProperties]))
);
};

const handleShowAddStylesInput = () => {
setIsAdding(true);
// User can click twice on the add button, so we need to focus the input on the second click after autoFocus isn't working.
Expand All @@ -692,15 +631,6 @@ export const Section = () => {
setSearchProperties(undefined);
};

const handleSubmitStyles = (cssText: string) => {
setIsAdding(false);
const styles = insertStyles(cssText);
const insertedProperties = styles.map(({ property }) => property);
setRecentProperties(
Array.from(new Set([...recentProperties, ...insertedProperties]))
);
};

const handleSearch = (event: ChangeEvent<HTMLInputElement>) => {
const search = event.target.value.trim();
if (search === "") {
Expand Down Expand Up @@ -733,68 +663,80 @@ export const Section = () => {
onAbort={handleAbortSearch}
/>
</Box>
<Box css={{ paddingInline: theme.panel.paddingInline }}>
{showRecentProperties &&
recentProperties.map((property, index, properties) => {
const isLast = index === properties.length - 1;
return (
<AdvancedProperty
valueInputRef={isLast ? recentValueInputRef : undefined}
key={property}
property={property}
autoFocus={isLast}
onChangeComplete={(event) => {
if (event.type === "enter") {
handleShowAddStylesInput();
}
}}
onReset={() => {
setRecentProperties((properties) => {
return properties.filter(
(recentProperty) => recentProperty !== property
);
});
}}
/>
);
})}
{(showRecentProperties || isAdding) && (
<CopyPasteMenu
onPaste={handleInsertStyles}
properties={currentProperties}
>
<Flex gap="2" direction="column">
<Box css={{ paddingInline: theme.panel.paddingInline }}>
{showRecentProperties &&
recentProperties.map((property, index, properties) => {
const isLast = index === properties.length - 1;
return (
<AdvancedProperty
valueInputRef={isLast ? recentValueInputRef : undefined}
key={property}
property={property}
autoFocus={isLast}
onChangeComplete={(event) => {
if (event.type === "enter") {
handleShowAddStylesInput();
}
}}
onReset={() => {
setRecentProperties((properties) => {
return properties.filter(
(recentProperty) => recentProperty !== property
);
});
}}
/>
);
})}
{(showRecentProperties || isAdding) && (
<Box
style={
isAdding
? { paddingTop: theme.spacing[3] }
: // We hide it visually so you can tab into it to get shown.
{ overflow: "hidden", height: 0 }
}
>
<AddProperty
onSubmit={(cssText: string) => {
setIsAdding(false);
handleInsertStyles(cssText);
}}
onClose={handleAbortAddStyles}
onFocus={() => {
if (isAdding === false) {
handleShowAddStylesInput();
}
}}
onBlur={() => {
setIsAdding(false);
}}
ref={addPropertyInputRef}
/>
</Box>
)}
</Box>
{showRecentProperties && <Separator />}
<Box
style={
isAdding
? { paddingTop: theme.spacing[3] }
: // We hide it visually so you can tab into it to get shown.
{ overflow: "hidden", height: 0 }
}
css={{ paddingInline: theme.panel.paddingInline }}
style={{ minHeight }}
ref={containerRef}
>
<AddProperty
onSubmit={handleSubmitStyles}
onClose={handleAbortAddStyles}
onFocus={() => {
if (isAdding === false) {
handleShowAddStylesInput();
}
}}
onBlur={() => {
setIsAdding(false);
}}
ref={addPropertyInputRef}
/>
{currentProperties
.filter(
(property) => recentProperties.includes(property) === false
)
.map((property) => (
<AdvancedProperty key={property} property={property} />
))}
</Box>
)}
</Box>
{showRecentProperties && <Separator />}
<Box
css={{ paddingInline: theme.panel.paddingInline }}
style={{ minHeight }}
ref={containerRef}
>
{currentProperties
.filter((property) => recentProperties.includes(property) === false)
.map((property) => (
<AdvancedProperty key={property} property={property} />
))}
</Box>
</Flex>
</CopyPasteMenu>
</AdvancedStyleSection>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { useRef, type ReactNode } from "react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
theme,
} from "@webstudio-is/design-system";
import { generateStyleMap, mergeStyles } from "@webstudio-is/css-engine";
import { useStore } from "@nanostores/react";
import { $advancedStyles } from "./stores";

export const propertyContainerAttribute = "data-property";

export const CopyPasteMenu = ({
children,
properties,
onPaste,
}: {
children: ReactNode;
properties: Array<string>;
onPaste: (cssText: string) => void;
}) => {
const advancedStyles = useStore($advancedStyles);
const lastClickedProperty = useRef<string>();

const handlePaste = () => {
navigator.clipboard.readText().then(onPaste);
};

const handleCopyAll = () => {
// We want to only copy properties that are currently in front of the user.
// That includes search or any future filters.
const currentStyleMap = new Map();
for (const [property, value] of advancedStyles) {
if (properties.includes(property)) {
currentStyleMap.set(property, value);
}
}
const css = generateStyleMap({ style: mergeStyles(currentStyleMap) });
navigator.clipboard.writeText(css);
};

const handleCopy = () => {
const property = lastClickedProperty.current;
if (property === undefined) {
return;
}
const value = advancedStyles.get(property);
if (value === undefined) {
return;
}
const style = new Map([[property, value]]);
const css = generateStyleMap({ style });
navigator.clipboard.writeText(css);
};

return (
<ContextMenu>
<ContextMenuTrigger
asChild
onPointerDown={(event) => {
if (!(event.target instanceof HTMLElement)) {
return;
}
const property = event.target.closest(
`[${propertyContainerAttribute}]`
)?.dataset.property;
lastClickedProperty.current = property;
}}
>
{children}
</ContextMenuTrigger>
<ContextMenuContent css={{ width: theme.spacing[25] }}>
<ContextMenuItem onSelect={handleCopy}>
Copy declaration
</ContextMenuItem>
<ContextMenuItem onSelect={handleCopyAll}>
Copy all declarations
</ContextMenuItem>
<ContextMenuItem onSelect={handlePaste}>
Paste declarations
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
};
Loading
Loading