astro react ace markdown editor and previewer
src/components/AceMarkdown.tsx
:
import { useRef, useEffect, useState } from "react";
import { $paper, type Paper } from "../store/paper";
import ace from "ace-builds/src-min-noconflict/ace.js";
import { marked } from "marked";
type genWhatType = "outline" | "content";
type modeType = "edit" | "preview";
interface AceMarkdownProps {
genWhat: "outline";
}
export function AceMarkdown({ genWhat }: AceMarkdownProps) {
const abort = useRef<any>(null);
const abortElement = useRef<HTMLButtonElement>(null);
const containerElement = useRef<HTMLDivElement>(null);
const editor = useRef<any>(null);
const editorElement = useRef<HTMLDivElement>(null);
const [fullScreen, setFullScreen] = useState<boolean>(false);
const [mode, setMode] = useState<modeType>("preview");
const previewerElement = useRef<HTMLDivElement>(null);
useEffect(() => {
ace.config.set("basePath", "/ace");
const aceEditor = ace.edit(editorElement.current, {
// fontSize: "14px",
mode: "ace/mode/markdown",
// theme: "ace/theme/monokai",
wrap: true,
});
editor.current = aceEditor;
aceEditor.value =
genWhat === "outline" ? $paper.value.outline : $paper.value.content;
// editor.renderer.attachToShadowRoot(); // !!!important
aceEditor.on("input", () => {
updatePreview();
});
updatePreview();
}, []);
const fullscreenIcon = () => {
return fullScreen ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<g fill="none" fill-rule="evenodd">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M19 3a2 2 0 0 1 1.995 1.85L21 5v10a2 2 0 0 1-1.85 1.995L19 17h-2v2a2 2 0 0 1-1.85 1.995L15 21H5a2 2 0 0 1-1.995-1.85L3 19V9a2 2 0 0 1 1.85-1.995L5 7h2V5a2 2 0 0 1 1.85-1.995L9 3zm-4 6H5v10h10zm4-4H9v2h6l.15.005a2 2 0 0 1 1.844 1.838L17 9v6h2z"
/>
</g>
<title>还原</title>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 21 21"
>
<path
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
d="M18.5 7.5V2.522l-5.5.014m5.5-.014l-6 5.907m.5 10.092l5.5.002l-.013-5.5m.013 5.406l-6-5.907M2.5 7.5v-5H8m.5 5.929l-6-5.907M8 18.516l-5.5.007V13.5m6-1l-6 6"
/>
<title>全屏</title>
</svg>
);
};
const fullscreenSwitch = () => {
setFullScreen(!fullScreen);
if (fullScreen) {
containerElement.current?.requestFullscreen();
if (containerElement.current) {
containerElement.current.style.height = "100vh";
}
} else {
document.exitFullscreen();
if (containerElement.current) {
containerElement.current.style.height = "40vh";
}
}
};
const modeIcon = () => {
return mode === "edit" ? (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 16 16"
>
<path
fill="currentColor"
fill-rule="evenodd"
d="M2 2h12l1 1v10l-1 1H2l-1-1V3zm0 11h12V3H2zm11-9H3v3h10zm-1 2H4V5h8zm-3 6h4V8H9zm1-3h2v2h-2zM7 8H3v1h4zm-4 3h4v1H3z"
clip-rule="evenodd"
/>
<title>预览</title>
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<g fill="none">
<path d="m12.593 23.258l-.011.002l-.071.035l-.02.004l-.014-.004l-.071-.035q-.016-.005-.024.005l-.004.01l-.017.428l.005.02l.01.013l.104.074l.015.004l.012-.004l.104-.074l.012-.016l.004-.017l-.017-.427q-.004-.016-.017-.018m.265-.113l-.013.002l-.185.093l-.01.01l-.003.011l.018.43l.005.012l.008.007l.201.093q.019.005.029-.008l.004-.014l-.034-.614q-.005-.018-.02-.022m-.715.002a.02.02 0 0 0-.027.006l-.006.014l-.034.614q.001.018.017.024l.015-.002l.201-.093l.01-.008l.004-.011l.017-.43l-.003-.012l-.01-.01z" />
<path
fill="currentColor"
d="M13 3a1 1 0 0 1 .117 1.993L13 5H5v14h14v-8a1 1 0 0 1 1.993-.117L21 11v8a2 2 0 0 1-1.85 1.995L19 21H5a2 2 0 0 1-1.995-1.85L3 19V5a2 2 0 0 1 1.85-1.995L5 3zm6.243.343a1 1 0 0 1 1.497 1.32l-.083.095l-9.9 9.899a1 1 0 0 1-1.497-1.32l.083-.094z"
/>
</g>
<title>编辑</title>
</svg>
);
};
const save = () => {
const fileParts = [editor.current.value];
const blob = new Blob(fileParts, { type: "text/plain" });
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "paper.md";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const updatePreview = () => {
const content = editor.current.value;
updatePreviewContent(content);
};
const updatePreviewContent = (content: string) => {
if (genWhat === "outline") {
$paper.value.outline = content;
} else {
$paper.value.content = content;
}
if (previewerElement.current) {
previewerElement.current.innerHTML = marked(content).toString();
}
};
return (
<div ref={containerElement} style={{ height: "40vh" }}>
<div>
<div style={{ display: "flex", justifyContent: "start" }}>
<button
hidden
onClick={() => {
if (abort) {
abort.current.abort();
if (abortElement.current) {
abortElement.current.hidden = true;
}
}
}}
ref={abortElement}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<circle cx="12" cy="3" r="0" fill="currentColor">
<animate
id="svgSpinners6DotsScale0"
fill="freeze"
attributeName="r"
begin="0;svgSpinners6DotsScale2.end-0.5s"
calcMode="spline"
dur="0.6s"
keySplines="0,1,0,1;.53,0,.61,.73"
keyTimes="0;.2;1"
values="0;2;0"
/>
</circle>
<circle cx="16.5" cy="4.21" r="0" fill="currentColor">
<animate
id="svgSpinners6DotsScale1"
fill="freeze"
attributeName="r"
begin="svgSpinners6DotsScale0.begin+0.1s"
calcMode="spline"
dur="0.6s"
keySplines="0,1,0,1;.53,0,.61,.73"
keyTimes="0;.2;1"
values="0;2;0"
/>
</circle>
<circle cx="7.5" cy="4.21" r="0" fill="currentColor">
<animate
id="svgSpinners6DotsScale2"
fill="freeze"
attributeName="r"
begin="svgSpinners6DotsScale4.begin+0.1s"
calcMode="spline"
dur="0.6s"
keySplines="0,1,0,1;.53,0,.61,.73"
keyTimes="0;.2;1"
values="0;2;0"
/>
</circle>
<circle cx="19.79" cy="7.5" r="0" fill="currentColor">
<animate
id="svgSpinners6DotsScale3"
fill="freeze"
attributeName="r"
begin="svgSpinners6DotsScale1.begin+0.1s"
calcMode="spline"
dur="0.6s"
keySplines="0,1,0,1;.53,0,.61,.73"
keyTimes="0;.2;1"
values="0;2;0"
/>
</circle>
<circle cx="4.21" cy="7.5" r="0" fill="currentColor">
<animate
id="svgSpinners6DotsScale4"
fill="freeze"
attributeName="r"
begin="svgSpinners6DotsScale6.begin+0.1s"
calcMode="spline"
dur="0.6s"
keySplines="0,1,0,1;.53,0,.61,.73"
keyTimes="0;.2;1"
values="0;2;0"
/>
</circle>
<circle cx="21" cy="12" r="0" fill="currentColor">
<animate
id="svgSpinners6DotsScale5"
fill="freeze"
attributeName="r"
begin="svgSpinners6DotsScale3.begin+0.1s"
calcMode="spline"
dur="0.6s"
keySplines="0,1,0,1;.53,0,.61,.73"
keyTimes="0;.2;1"
values="0;2;0"
/>
</circle>
<circle cx="3" cy="12" r="0" fill="currentColor">
<animate
id="svgSpinners6DotsScale6"
fill="freeze"
attributeName="r"
begin="svgSpinners6DotsScale8.begin+0.1s"
calcMode="spline"
dur="0.6s"
keySplines="0,1,0,1;.53,0,.61,.73"
keyTimes="0;.2;1"
values="0;2;0"
/>
</circle>
<circle cx="19.79" cy="16.5" r="0" fill="currentColor">
<animate
id="svgSpinners6DotsScale7"
fill="freeze"
attributeName="r"
begin="svgSpinners6DotsScale5.begin+0.1s"
calcMode="spline"
dur="0.6s"
keySplines="0,1,0,1;.53,0,.61,.73"
keyTimes="0;.2;1"
values="0;2;0"
/>
</circle>
<circle cx="4.21" cy="16.5" r="0" fill="currentColor">
<animate
id="svgSpinners6DotsScale8"
fill="freeze"
attributeName="r"
begin="svgSpinners6DotsScalea.begin+0.1s"
calcMode="spline"
dur="0.6s"
keySplines="0,1,0,1;.53,0,.61,.73"
keyTimes="0;.2;1"
values="0;2;0"
/>
</circle>
<circle cx="16.5" cy="19.79" r="0" fill="currentColor">
<animate
id="svgSpinners6DotsScale9"
fill="freeze"
attributeName="r"
begin="svgSpinners6DotsScale7.begin+0.1s"
calcMode="spline"
dur="0.6s"
keySplines="0,1,0,1;.53,0,.61,.73"
keyTimes="0;.2;1"
values="0;2;0"
/>
</circle>
<circle cx="7.5" cy="19.79" r="0" fill="currentColor">
<animate
id="svgSpinners6DotsScalea"
fill="freeze"
attributeName="r"
begin="svgSpinners6DotsScaleb.begin+0.1s"
calcMode="spline"
dur="0.6s"
keySplines="0,1,0,1;.53,0,.61,.73"
keyTimes="0;.2;1"
values="0;2;0"
/>
</circle>
<circle cx="12" cy="21" r="0" fill="currentColor">
<animate
id="svgSpinners6DotsScaleb"
fill="freeze"
attributeName="r"
begin="svgSpinners6DotsScale9.begin+0.1s"
calcMode="spline"
dur="0.6s"
keySplines="0,1,0,1;.53,0,.61,.73"
keyTimes="0;.2;1"
values="0;2;0"
/>
</circle>
<title>终止</title>
</svg>
</button>
</div>
<div style={{ display: "flex", justifyContent: "end" }}>
<button
onClick={() => {
save();
}}
type="button"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M21 7v12q0 .825-.587 1.413T19 21H5q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h12zm-2 .85L16.15 5H5v14h14zM12 18q1.25 0 2.125-.875T15 15t-.875-2.125T12 12t-2.125.875T9 15t.875 2.125T12 18m-6-8h9V6H6zM5 7.85V19V5z"
/>
<title>下载</title>
</svg>
</button>
<button
onClick={() => {
mode === "edit" ? setMode("preview") : setMode("edit");
}}
type="button"
>
{modeIcon()}
</button>
<button
onClick={() => {
fullscreenSwitch();
}}
type="button"
>
{fullscreenIcon()}
</button>
</div>
</div>
<div style={{ display: "flex", height: "100%" }}>
<div hidden={mode === "preview"} style={{ flex: "50%" }}>
<div ref={editorElement} style={{ width: "100%", height: "100%" }} />
</div>
<div style={{ flex: "50%" }}>
<div
ref={previewerElement}
style={{
color: "black",
background: "white",
width: "100%",
height: "100%",
overflow: "auto",
}}
/>
</div>
</div>
</div>
);
}
justfile
:
build:
#!/usr/bin/env bash
cp node_modules/ace-builds/src-min-noconflict/mode-markdown.js public/ace/
cp node_modules/ace-builds/src-min-noconflict/theme-monokai.js public/ace/
注:ace editor样式存在异常,显示错位。