astro react ace markdown editor and previewer

卓能文發表於2024-08-27

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樣式存在異常,顯示錯位。

相關文章