Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
b538a63
feat: open text assets in code editor
kof Jul 18, 2026
1ad9cbf
refactor: open text assets in dialog
kof Jul 18, 2026
2bc12fc
fix: allow editing text asset contents
kof Jul 18, 2026
300f21f
feat: persist text asset edits
kof Jul 18, 2026
3d78f1e
refactor: derive text editor languages from asset types
kof Jul 18, 2026
9d9ece5
test: destructure asset testing helpers
kof Jul 18, 2026
37a5716
fix: open text assets on explicit activation
kof Jul 18, 2026
5a4d8ea
feat: preserve asset identity when editing text files
kof Jul 18, 2026
bf6e465
feat: expose text asset editing to MCP
kof Jul 18, 2026
e7d3da8
fix: keep asset content saves same origin
kof Jul 18, 2026
af9dd24
feat: upload dropped asset folders
kof Jul 18, 2026
b145144
fix: serve unoptimized image assets directly
kof Jul 18, 2026
42a536f
fix: show fallback thumbnails for unoptimized images
kof Jul 18, 2026
cb431ea
fix: simplify asset editing workflows
kof Jul 18, 2026
4f56944
refactor: simplify text asset workflows
kof Jul 18, 2026
d43eee5
refactor: centralize text asset detection
kof Jul 18, 2026
1c9042a
refactor: streamline asset revision plumbing
kof Jul 19, 2026
fc42694
refactor: simplify editor language extensions
kof Jul 19, 2026
a44081a
refactor: simplify asset revision checks
kof Jul 19, 2026
14b9c82
fix: center maximized editor dialogs
kof Jul 19, 2026
87366df
feat: add Markdown asset formatting toolbar
kof Jul 19, 2026
2314f3e
fix: preserve spacing around maximized dialogs
kof Jul 19, 2026
690559d
feat: toggle dialog maximize from title
kof Jul 19, 2026
2fd95f9
feat: expand Markdown formatting toolbar
kof Jul 19, 2026
1280246
style: match Markdown toolbar to editor
kof Jul 19, 2026
cb44c4c
style: remove asset editor dialog padding
kof Jul 19, 2026
7eeb75c
feat: select Markdown heading levels
kof Jul 19, 2026
08b8cbf
feat: show asset timestamps
kof Jul 19, 2026
3de3f0e
style: hide Markdown editor focus ring
kof Jul 19, 2026
2b51b89
fix: show Markdown bullet list icon
kof Jul 19, 2026
7a33093
style: hide Markdown toolbar scrollbar
kof Jul 19, 2026
39b0e01
style: remove text editor dialog border
kof Jul 19, 2026
cc65a0a
feat: preview Markdown in split view
kof Jul 19, 2026
a32c602
refactor: simplify asset editor changes
kof Jul 19, 2026
4b63180
feat: pick images for Markdown
kof Jul 19, 2026
1f2713c
feat: pick Markdown link destinations
kof Jul 19, 2026
f9ef6d3
fix: pin Markdown preview toggle right
kof Jul 19, 2026
8a106c0
fix: load assets in late-mounted pickers
kof Jul 19, 2026
0002b5a
fix: resolve assets in Markdown preview
kof Jul 19, 2026
5b4cb93
fix: preview uploading Markdown images
kof Jul 19, 2026
756947c
refactor: reuse text toolbar button styles
kof Jul 19, 2026
c254be4
fix: space Markdown link picker
kof Jul 19, 2026
2adf403
test: update asset fixture bundles
kof Jul 19, 2026
bdeac57
fix: complete asset replacements reliably
kof Jul 19, 2026
ab83c82
fix: preserve asset creation time across edits
kof Jul 20, 2026
9eaaf12
refactor: make asset editor support exhaustive
kof Jul 20, 2026
46a1602
fix: complete MCP asset editing parity
kof Jul 20, 2026
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
32 changes: 32 additions & 0 deletions apps/builder/app/builder/features/assets/assets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,17 @@ import {
import { BrushCleaningIcon, NewFolderIcon } from "@webstudio-is/icons";
import { useRef, useState } from "react";
import { useStore } from "@nanostores/react";
import { isTextFileAsset } from "@webstudio-is/sdk";
import { AssetManager } from "~/builder/shared/asset-manager";
import { AssetUpload, type AssetUploadHandle } from "~/builder/shared/assets";
import { openDeleteUnusedAssetsDialog } from "~/builder/shared/asset-manager/delete-unused-assets";
import { CreateAssetFolderDialog } from "~/builder/shared/asset-manager/asset-folder-dialogs";
import { $authPermit } from "~/shared/nano-states";
import { $assets } from "~/shared/sync/data-stores";
import type { Publish } from "~/shared/pubsub";
import { useImageAssetCanvasDrag } from "./use-image-asset-canvas-drag";
import { TextFileEditor } from "~/builder/features/text-file-editor/text-file-editor";
import { getAssetUrl } from "~/builder/shared/assets/asset-utils";

export const AssetsPanel = ({
publish,
Expand All @@ -23,8 +27,24 @@ export const AssetsPanel = ({
}) => {
const [folderId, setFolderId] = useState<string>();
const [createFolderOpen, setCreateFolderOpen] = useState(false);
const [openedTextAssetId, setOpenedTextAssetId] = useState<string>();
const uploadRef = useRef<AssetUploadHandle>(null);
const authPermit = useStore($authPermit);
const openAsset = (assetId: string) => {
const asset = $assets.get().get(assetId);
if (asset === undefined) {
return;
}
if (isTextFileAsset(asset)) {
setOpenedTextAssetId(assetId);
return;
}
window.open(
getAssetUrl(asset, window.location.origin),
"_blank",
"noopener,noreferrer"
);
};
useImageAssetCanvasDrag(publish);
return (
<>
Expand Down Expand Up @@ -55,6 +75,7 @@ export const AssetsPanel = ({
<AssetManager
folderId={folderId}
onFolderChange={setFolderId}
onOpen={openAsset}
canManageFolders={authPermit !== "view"}
panelActions={{
...(authPermit === "view"
Expand All @@ -71,6 +92,17 @@ export const AssetsPanel = ({
onOpenChange={setCreateFolderOpen}
currentFolderId={folderId}
/>
{openedTextAssetId !== undefined && (
<TextFileEditor
key={openedTextAssetId}
assetId={openedTextAssetId}
onOpenChange={(open) => {
if (open === false) {
setOpenedTextAssetId(undefined);
}
}}
/>
)}
</>
);
};
1 change: 1 addition & 0 deletions apps/builder/app/builder/features/help/remote-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const RemoteDialog = () => {
* to make the close button last in the tab order
*/}
<DialogTitle
maximizable
suffix={
<DialogTitleActions>
<IconButton
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export const CodeControl = ({
lang={lang}
title={
<DialogTitle
maximizable
suffix={
<DialogTitleActions>
<DialogMaximize />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import { useMemo } from "react";
import { computed } from "nanostores";
import { useStore } from "@nanostores/react";
import { Button, Flex, Text, FloatingPanel } from "@webstudio-is/design-system";
import type { Prop } from "@webstudio-is/sdk";
import { acceptToMimeCategories } from "@webstudio-is/sdk";
import { $assets } from "~/shared/sync/data-stores";
import { AssetManager } from "~/builder/shared/asset-manager";
import { type ControlProps } from "../shared";
import { type PropValue } from "../shared";
import { formatAssetName } from "@webstudio-is/project-build/runtime";
import { AssetUpload } from "~/builder/shared/assets";

Expand All @@ -19,12 +18,10 @@ const isImageAccept = (accept?: string) => {
);
};

type AssetControlProps = ControlProps<unknown>;

type Props = {
accept?: string;
prop?: Extract<Prop, { type: "asset" }>;
onChange: AssetControlProps["onChange"];
prop?: Extract<PropValue, { type: "asset" }>;
onChange: (value: Extract<PropValue, { type: "asset" }>) => void;
};

export const SelectAsset = ({ prop, onChange, accept }: Props) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ export const TextContent = ({
<CodeEditor
title={
<DialogTitle
maximizable
suffix={
<DialogTitleActions>
<DialogMaximize />
Expand Down
142 changes: 96 additions & 46 deletions apps/builder/app/builder/features/settings-panel/controls/url.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,20 +44,31 @@ import {
$selectedInstanceScope,
useBindingState,
humanizeAttribute,
type PropValue,
} from "../shared";
import { SelectAsset } from "./select-asset";
import { createRootFolder } from "@webstudio-is/project-build";
import { PropertyLabel } from "../property-label";

type UrlControlProps = ControlProps<"url">;

export type UrlInputValue = Extract<
PropValue,
{ type: "string" | "page" | "asset" }
>;

type UrlInputProp =
| UrlInputValue
| { type: "expression"; value: string }
| undefined;

type BaseControlProps = {
id: string;
instanceId: string;
readOnly: boolean;
prop: UrlControlProps["prop"];
prop: UrlInputProp;
value: string;
onChange: UrlControlProps["onChange"];
onChange: (value: UrlInputValue) => void;
};

const Row = ({ children }: { children: ReactNode }) => (
Expand Down Expand Up @@ -442,10 +453,7 @@ const modes = {

type Mode = keyof typeof modes;

const propToMode = (
prop: undefined | UrlControlProps["prop"],
value: string
): Mode => {
const propToMode = (prop: UrlInputProp, value: string): Mode => {
if (prop === undefined) {
return "url";
}
Expand All @@ -469,34 +477,40 @@ const propToMode = (
return "url";
};

export const UrlControl = ({
export const UrlInput = ({
instanceId,
meta,
prop,
propName,
computedValue,
value,
readOnly = false,
onChange,
}: UrlControlProps) => {
const value = String(computedValue ?? "");
suffix,
}: {
instanceId: string;
prop: UrlInputProp;
value: string;
readOnly?: boolean;
onChange: (value: UrlInputValue) => void;
suffix?: ReactNode;
}) => {
const { value: mode, set: setMode } = useDraftValue<Mode>(
propToMode(prop, value),
() => {}
);

const id = useId();

const BaseControl = modes[mode].control;

const label = humanizeAttribute(meta.label || propName);
const { scope, aliases } = useStore($selectedInstanceScope);
const expression =
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
const { overwritable, variant } = useBindingState(
prop?.type === "expression" ? prop.value : undefined
const control = (
<BaseControl
id={id}
instanceId={instanceId}
readOnly={readOnly}
prop={prop}
value={value}
onChange={onChange}
/>
);

return (
<VerticalLayout label={<PropertyLabel name={propName} />}>
<>
<Flex
css={{
py: theme.spacing[2],
Expand All @@ -508,7 +522,7 @@ export const UrlControl = ({
>
<ToggleGroup
type="single"
disabled={overwritable === false}
disabled={readOnly}
value={mode}
onValueChange={(value) => {
// too tricky to prove to TS that value is a Mode
Expand All @@ -523,30 +537,66 @@ export const UrlControl = ({
))}
</ToggleGroup>
</Flex>
{suffix === undefined ? (
control
) : (
<BindingControl>
{control}
{suffix}
</BindingControl>
)}
</>
);
};

<BindingControl>
<BaseControl
id={id}
instanceId={instanceId}
readOnly={overwritable === false}
prop={prop}
value={value}
onChange={onChange}
/>
<BindingPopover
scope={scope}
aliases={aliases}
validate={(value) => validatePrimitiveValue(value, label)}
variant={variant}
value={expression}
onChange={(newExpression) =>
onChange({ type: "expression", value: newExpression })
}
onRemove={(evaluatedValue) =>
onChange({ type: "string", value: String(evaluatedValue) })
}
/>
</BindingControl>
export const UrlControl = ({
instanceId,
meta,
prop,
propName,
computedValue,
onChange,
}: UrlControlProps) => {
const value = String(computedValue ?? "");
const label = humanizeAttribute(meta.label || propName);
const { scope, aliases } = useStore($selectedInstanceScope);
const expression =
prop?.type === "expression" ? prop.value : JSON.stringify(computedValue);
const { overwritable, variant } = useBindingState(
prop?.type === "expression" ? prop.value : undefined
);

return (
<VerticalLayout label={<PropertyLabel name={propName} />}>
<UrlInput
instanceId={instanceId}
prop={
prop?.type === "string" ||
prop?.type === "page" ||
prop?.type === "asset" ||
prop?.type === "expression"
? prop
: undefined
}
value={value}
readOnly={overwritable === false}
onChange={onChange}
suffix={
<BindingPopover
scope={scope}
aliases={aliases}
validate={(value) => validatePrimitiveValue(value, label)}
variant={variant}
value={expression}
onChange={(newExpression) =>
onChange({ type: "expression", value: newExpression })
}
onRemove={(evaluatedValue) =>
onChange({ type: "string", value: String(evaluatedValue) })
}
/>
}
/>
</VerticalLayout>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -952,6 +952,7 @@ const VariablePopoverContent = ({
</Grid>

<DialogTitle
maximizable
suffix={
<DialogTitleActions>
{(variableType === "resource" ||
Expand Down Expand Up @@ -1026,6 +1027,7 @@ export const VariablePopoverTrigger = ({

return (
<FloatingPanel
maximizable
placement="center"
width={740}
height={480}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { expect, test } from "vitest";
import type { Asset } from "@webstudio-is/sdk";
import { __testing__ } from "./markdown-preview";
import { renderMarkdown } from "./text-file-utils";

const { resolveAssetReferences } = __testing__;

const image: Asset = {
id: "image-id",
projectId: "project-id",
type: "image",
name: "image.png",
format: "png",
size: 1,
meta: { width: 1, height: 1 },
createdAt: "2026-01-01T00:00:00.000Z",
};

test("resolves asset IDs in Markdown images and links", () => {
const html = resolveAssetReferences({
html: renderMarkdown(
"![Local image](image-id)\n\n[Download image](image-id)\n\n![Remote image](https://example.com/image.png)"
),
assetContainers: [{ status: "uploaded", asset: image }],
origin: "https://builder.example",
});

expect(html).toContain(
'src="https://builder.example/cgi/image/image.png?format=raw"'
);
expect(html).toContain(
'href="https://builder.example/cgi/image/image.png?format=raw"'
);
expect(html).toContain('src="https://example.com/image.png"');
});

test("uses an object URL while an inserted image is uploading", () => {
const html = resolveAssetReferences({
html: renderMarkdown("![Uploading](image-id)"),
assetContainers: [
{
status: "uploading",
asset: image,
objectURL: "blob:https://builder.example/upload",
},
],
origin: "https://builder.example",
});

expect(html).toContain('src="blob:https://builder.example/upload"');
});
Loading
Loading