diff --git a/apps/builder/app/builder/features/assets/assets.tsx b/apps/builder/app/builder/features/assets/assets.tsx index b85fcd85730c..4c6cec0011d0 100644 --- a/apps/builder/app/builder/features/assets/assets.tsx +++ b/apps/builder/app/builder/features/assets/assets.tsx @@ -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, @@ -23,8 +27,24 @@ export const AssetsPanel = ({ }) => { const [folderId, setFolderId] = useState(); const [createFolderOpen, setCreateFolderOpen] = useState(false); + const [openedTextAssetId, setOpenedTextAssetId] = useState(); const uploadRef = useRef(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 ( <> @@ -55,6 +75,7 @@ export const AssetsPanel = ({ + {openedTextAssetId !== undefined && ( + { + if (open === false) { + setOpenedTextAssetId(undefined); + } + }} + /> + )} ); }; diff --git a/apps/builder/app/builder/features/help/remote-dialog.tsx b/apps/builder/app/builder/features/help/remote-dialog.tsx index 2455f4685bfe..6f75c4513c9e 100644 --- a/apps/builder/app/builder/features/help/remote-dialog.tsx +++ b/apps/builder/app/builder/features/help/remote-dialog.tsx @@ -39,6 +39,7 @@ export const RemoteDialog = () => { * to make the close button last in the tab order */} diff --git a/apps/builder/app/builder/features/settings-panel/controls/select-asset.tsx b/apps/builder/app/builder/features/settings-panel/controls/select-asset.tsx index 1c75c2782d8d..a9106259d8c5 100644 --- a/apps/builder/app/builder/features/settings-panel/controls/select-asset.tsx +++ b/apps/builder/app/builder/features/settings-panel/controls/select-asset.tsx @@ -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"; @@ -19,12 +18,10 @@ const isImageAccept = (accept?: string) => { ); }; -type AssetControlProps = ControlProps; - type Props = { accept?: string; - prop?: Extract; - onChange: AssetControlProps["onChange"]; + prop?: Extract; + onChange: (value: Extract) => void; }; export const SelectAsset = ({ prop, onChange, accept }: Props) => { diff --git a/apps/builder/app/builder/features/settings-panel/controls/text-content.tsx b/apps/builder/app/builder/features/settings-panel/controls/text-content.tsx index 151af05bbf6f..d162514ab88f 100644 --- a/apps/builder/app/builder/features/settings-panel/controls/text-content.tsx +++ b/apps/builder/app/builder/features/settings-panel/controls/text-content.tsx @@ -127,6 +127,7 @@ export const TextContent = ({ diff --git a/apps/builder/app/builder/features/settings-panel/controls/url.tsx b/apps/builder/app/builder/features/settings-panel/controls/url.tsx index 3c2bcd6d9f89..02c33c7fac06 100644 --- a/apps/builder/app/builder/features/settings-panel/controls/url.tsx +++ b/apps/builder/app/builder/features/settings-panel/controls/url.tsx @@ -44,6 +44,7 @@ import { $selectedInstanceScope, useBindingState, humanizeAttribute, + type PropValue, } from "../shared"; import { SelectAsset } from "./select-asset"; import { createRootFolder } from "@webstudio-is/project-build"; @@ -51,13 +52,23 @@ 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 }) => ( @@ -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"; } @@ -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( 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 = ( + ); return ( - }> + <> { // too tricky to prove to TS that value is a Mode @@ -523,30 +537,66 @@ export const UrlControl = ({ ))} + {suffix === undefined ? ( + control + ) : ( + + {control} + {suffix} + + )} + + ); +}; - - - validatePrimitiveValue(value, label)} - variant={variant} - value={expression} - onChange={(newExpression) => - onChange({ type: "expression", value: newExpression }) - } - onRemove={(evaluatedValue) => - onChange({ type: "string", value: String(evaluatedValue) }) - } - /> - +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 ( + }> + validatePrimitiveValue(value, label)} + variant={variant} + value={expression} + onChange={(newExpression) => + onChange({ type: "expression", value: newExpression }) + } + onRemove={(evaluatedValue) => + onChange({ type: "string", value: String(evaluatedValue) }) + } + /> + } + /> ); }; diff --git a/apps/builder/app/builder/features/settings-panel/variable-popover.tsx b/apps/builder/app/builder/features/settings-panel/variable-popover.tsx index 74ee890e78ad..691e83ce87f4 100644 --- a/apps/builder/app/builder/features/settings-panel/variable-popover.tsx +++ b/apps/builder/app/builder/features/settings-panel/variable-popover.tsx @@ -952,6 +952,7 @@ const VariablePopoverContent = ({ {(variableType === "resource" || @@ -1026,6 +1027,7 @@ export const VariablePopoverTrigger = ({ return ( { + 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"'); +}); diff --git a/apps/builder/app/builder/features/text-file-editor/markdown-preview.tsx b/apps/builder/app/builder/features/text-file-editor/markdown-preview.tsx new file mode 100644 index 000000000000..3dece892edf9 --- /dev/null +++ b/apps/builder/app/builder/features/text-file-editor/markdown-preview.tsx @@ -0,0 +1,241 @@ +import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import { + Box, + css, + numericScrubControl, + theme, +} from "@webstudio-is/design-system"; +import type { AssetContainer } from "~/builder/shared/assets"; +import { getAssetUrl } from "~/builder/shared/assets/asset-utils"; +import { renderMarkdown } from "./text-file-utils"; + +const minimumPaneWidth = 160; + +const previewStyle = css({ + minWidth: 0, + height: "100%", + overflow: "auto", + boxSizing: "border-box", + padding: theme.spacing[9], + color: theme.colors.foregroundMain, + background: theme.colors.backgroundPanel, + fontFamily: theme.fonts.sans, + fontSize: 14, + lineHeight: 1.5, + userSelect: "text", + "& > :first-child": { marginTop: 0 }, + "& > :last-child": { marginBottom: 0 }, + "& h1, & h2, & h3, & h4, & h5, & h6": { + marginBlock: "1em 0.5em", + fontWeight: 600, + lineHeight: 1.25, + }, + "& h1": { fontSize: "2em" }, + "& h2": { fontSize: "1.5em" }, + "& h3": { fontSize: "1.25em" }, + "& p, & ul, & ol, & blockquote, & pre, & table": { + marginBlock: "1em", + }, + "& ul, & ol": { paddingLeft: theme.spacing[9] }, + "& ul": { listStyleType: "disc" }, + "& ol": { listStyleType: "decimal" }, + "& blockquote": { + marginInline: 0, + paddingLeft: theme.spacing[5], + color: theme.colors.foregroundSubtle, + borderLeft: `3px solid ${theme.colors.borderMain}`, + }, + "& code": { + padding: "0.125em 0.25em", + borderRadius: theme.borderRadius[3], + background: theme.colors.backgroundControls, + fontFamily: theme.fonts.mono, + }, + "& pre": { + overflowX: "auto", + padding: theme.spacing[5], + borderRadius: theme.borderRadius[4], + background: theme.colors.backgroundControls, + }, + "& pre code": { padding: 0, background: "transparent" }, + "& table": { width: "100%", borderCollapse: "collapse" }, + "& th, & td": { + padding: theme.spacing[3], + border: `1px solid ${theme.colors.borderMain}`, + textAlign: "left", + }, + "& img": { maxWidth: "100%" }, + "& a": { color: theme.colors.foregroundPrimary }, +}); + +const splitHandleStyle = css({ + position: "relative", + width: 5, + height: "100%", + cursor: "col-resize", + touchAction: "none", + outline: "none", + "&::before": { + position: "absolute", + content: '""', + top: 0, + bottom: 0, + left: 2, + width: 1, + background: theme.colors.backgroundPrimaryLight, + }, + "&:hover::before, &:focus-visible::before": { + left: 1, + width: 3, + }, +}); + +const clampRatio = (ratio: number) => Math.min(0.8, Math.max(0.2, ratio)); + +const resolveAssetReferences = ({ + html, + assetContainers, + origin, +}: { + html: string; + assetContainers: AssetContainer[]; + origin: string; +}) => { + if (typeof DOMParser === "undefined") { + return html; + } + + const document = new DOMParser().parseFromString(html, "text/html"); + const urls = new Map( + assetContainers.map((container) => [ + container.asset.id, + container.status === "uploading" + ? container.objectURL + : getAssetUrl(container.asset, origin).href, + ]) + ); + for (const image of document.querySelectorAll("img[src]")) { + const url = urls.get(image.getAttribute("src") ?? ""); + if (url !== undefined) { + image.setAttribute("src", url); + } + } + for (const link of document.querySelectorAll("a[href]")) { + const url = urls.get(link.getAttribute("href") ?? ""); + if (url !== undefined) { + link.setAttribute("href", url); + } + } + return document.body.innerHTML; +}; + +export const __testing__ = { resolveAssetReferences }; + +export const MarkdownSplitView = ({ + open, + source, + assetContainers, + children, +}: { + open: boolean; + source: string; + assetContainers: AssetContainer[]; + children: ReactNode; +}) => { + const containerRef = useRef(null); + const handleRef = useRef(null); + const ratioRef = useRef(0.5); + const [ratio, setRatio] = useState(ratioRef.current); + const html = useMemo(() => { + if (open === false) { + return ""; + } + return resolveAssetReferences({ + html: renderMarkdown(source), + assetContainers, + origin: window.location.origin, + }); + }, [assetContainers, open, source]); + + const updateRatio = (nextRatio: number) => { + ratioRef.current = clampRatio(nextRatio); + setRatio(ratioRef.current); + }; + + useEffect(() => { + const handle = handleRef.current; + if (handle === null) { + return; + } + + return numericScrubControl(handle, { + getInitialValue: () => { + const width = containerRef.current?.getBoundingClientRect().width ?? 0; + return width * ratioRef.current; + }, + getValue: (state, movement) => { + const width = containerRef.current?.getBoundingClientRect().width ?? 0; + const paneWidth = Math.min(minimumPaneWidth, width / 2); + return Math.min( + width - paneWidth, + Math.max(paneWidth, state.value + movement) + ); + }, + onValueInput: (event) => { + const width = containerRef.current?.getBoundingClientRect().width ?? 0; + if (width > 0) { + updateRatio(event.value / width); + } + }, + }); + }, []); + + return ( + + + {children} + +