Files
webstudio/apps/builder/app/shared/copy-paste/plugin-markdown.ts
T
Ivan Starkov 8665ba202c feat: Upload images on copy-paste (#3569)
## Description

Markdown paste with images

Take MD from 

https://github.com/webstudio-is/webstudio/pull/3569#issuecomment-2171213517

Paste, see images are uploaded

## 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` file
2024-06-16 20:40:09 +03:00

260 lines
6.4 KiB
TypeScript

import { nanoid } from "nanoid";
import { fromMarkdown } from "mdast-util-from-markdown";
import { gfmFromMarkdown } from "mdast-util-gfm";
import { gfm } from "micromark-extension-gfm";
import type {
Breakpoint,
Instance,
WebstudioFragment,
} from "@webstudio-is/sdk";
import {
computeInstancesConstraints,
findClosestDroppableTarget,
insertTemplateData,
} from "../instance-utils";
import {
$breakpoints,
$instances,
$registeredComponentMetas,
$selectedInstanceSelector,
$selectedPage,
} from "../nano-states";
import { isBaseBreakpoint } from "../breakpoints";
import { denormalizeSrcProps } from "./asset-upload";
const micromarkOptions = {
extensions: [gfm()],
mdastExtensions: [gfmFromMarkdown()],
};
export const mimeType = "text/plain";
// @todo Definition, Strikethrough, Table
const astTypeComponentMap: Record<string, Instance["component"]> = {
paragraph: "Paragraph",
heading: "Heading",
strong: "Bold",
emphasis: "Italic",
link: "RichTextLink",
// @todo image should not be rendered inside paragraph
// we need to either have RichTextImage or support Image inside RichText
image: "Image",
blockquote: "Blockquote",
code: "CodeText",
// @todo same problem as with image
inlineCode: "CodeText",
list: "List",
listItem: "ListItem",
thematicBreak: "Separator",
};
type Options = { generateId?: typeof nanoid };
type Root = ReturnType<typeof fromMarkdown>;
const toInstanceData = (
data: WebstudioFragment,
breakpointId: Breakpoint["id"],
ast: { children: Root["children"] },
options: Options = {}
): Instance["children"] => {
const { instances, props, styleSources, styleSourceSelections, styles } =
data;
const { generateId = nanoid } = options;
const children: Instance["children"] = [];
for (const child of ast.children) {
if (child.type === "text") {
children.push({ type: "text", value: child.value });
continue;
}
const component = astTypeComponentMap[child.type];
if (component === undefined) {
continue;
}
const instanceId = generateId();
const instance: Instance = {
type: "instance",
id: instanceId,
component,
children:
"children" in child
? toInstanceData(data, breakpointId, child, options)
: [],
};
instances.push(instance);
children.push({ type: "id", value: instanceId });
if (child.type === "heading") {
props.push({
id: generateId(),
type: "string",
name: "tag",
instanceId,
value: `h${child.depth}`,
});
}
if (child.type === "link") {
props.push({
id: generateId(),
type: "string",
name: "href",
instanceId,
value: child.url,
});
}
if (child.type === "image") {
props.push({
id: generateId(),
type: "string",
name: "src",
instanceId,
value: child.url,
});
}
if (child.type === "inlineCode") {
props.push({
id: generateId(),
type: "string",
name: "code",
instanceId,
value: child.value,
});
const styleSourceId = generateId();
styleSources.push({ type: "local", id: styleSourceId });
styleSourceSelections.push({ instanceId, values: [styleSourceId] });
styles.push({
breakpointId,
styleSourceId,
property: "display",
value: { type: "keyword", value: "inline-block" },
});
}
if (child.type === "code") {
props.push({
id: generateId(),
type: "string",
name: "code",
instanceId,
value: child.value,
});
if (child.lang) {
props.push({
id: generateId(),
type: "string",
name: "lang",
instanceId,
value: child.lang,
});
}
}
if (child.type === "list") {
if (typeof child.ordered === "boolean") {
props.push({
id: generateId(),
type: "boolean",
name: "ordered",
instanceId,
value: child.ordered,
});
}
if (typeof child.start === "number") {
props.push({
id: generateId(),
type: "number",
name: "start",
instanceId,
value: child.start,
});
}
}
if ("title" in child && child.title) {
props.push({
id: generateId(),
type: "string",
name: "title",
instanceId,
value: child.title,
});
}
if ("alt" in child && child.alt) {
props.push({
id: generateId(),
type: "string",
name: "alt",
instanceId,
value: child.alt,
});
}
}
return children;
};
const parse = (clipboardData: string, options?: Options) => {
const ast = fromMarkdown(clipboardData, micromarkOptions);
if (ast.children.length === 0) {
return;
}
const breakpoints = $breakpoints.get();
const breakpointValues = Array.from(breakpoints.values());
const baseBreakpoint = breakpointValues.find(isBaseBreakpoint);
if (baseBreakpoint === undefined) {
return;
}
const data: WebstudioFragment = {
children: [],
instances: [],
props: [],
breakpoints: [],
styles: [],
styleSources: [],
styleSourceSelections: [],
dataSources: [],
resources: [],
assets: [],
};
data.children = toInstanceData(data, baseBreakpoint.id, ast, options);
return data;
};
export const onPaste = async (clipboardData: string) => {
let data = parse(clipboardData);
const selectedPage = $selectedPage.get();
if (data === undefined || selectedPage === undefined) {
return false;
}
data = await denormalizeSrcProps(data);
const metas = $registeredComponentMetas.get();
const newInstances = new Map(
data.instances.map((instance) => [instance.id, instance])
);
const rootInstanceIds = data.children
.filter((child) => child.type === "id")
.map((child) => child.value);
// paste to the root if nothing is selected
const instanceSelector = $selectedInstanceSelector.get() ?? [
selectedPage.rootInstanceId,
];
const dropTarget = findClosestDroppableTarget(
metas,
$instances.get(),
instanceSelector,
computeInstancesConstraints(metas, newInstances, rootInstanceIds)
);
if (dropTarget === undefined) {
return false;
}
insertTemplateData(data, dropTarget);
return true;
};
export const __testing__ = {
parse,
};