migrated editing client
This commit is contained in:
177
src/components/blog/AddAttachmentSection.tsx
Normal file
177
src/components/blog/AddAttachmentSection.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { createSignal, createEffect, For, Show } from "solid-js";
|
||||
import Dropzone from "./Dropzone";
|
||||
import XCircle from "~/components/icons/XCircle";
|
||||
import AddImageToS3 from "~/lib/s3upload";
|
||||
|
||||
export interface AddAttachmentSectionProps {
|
||||
type: "blog" | "project";
|
||||
postId?: number;
|
||||
postTitle: string;
|
||||
existingAttachments?: string;
|
||||
}
|
||||
|
||||
export default function AddAttachmentSection(props: AddAttachmentSectionProps) {
|
||||
const [images, setImages] = createSignal<File[]>([]);
|
||||
const [imageHolder, setImageHolder] = createSignal<string[]>([]);
|
||||
const [newImageHolder, setNewImageHolder] = createSignal<string[]>([]);
|
||||
const [newImageHolderKeys, setNewImageHolderKeys] = createSignal<string[]>(
|
||||
[]
|
||||
);
|
||||
|
||||
createEffect(() => {
|
||||
if (props.existingAttachments) {
|
||||
const imgStringArr = props.existingAttachments.split(",");
|
||||
setImageHolder(imgStringArr);
|
||||
}
|
||||
});
|
||||
|
||||
const handleImageDrop = async (acceptedFiles: File[]) => {
|
||||
if (props.postTitle) {
|
||||
for (const file of acceptedFiles) {
|
||||
setImages((prev) => [...prev, file]);
|
||||
|
||||
try {
|
||||
const key = await AddImageToS3(file, props.postTitle, props.type);
|
||||
if (key) {
|
||||
setNewImageHolderKeys((prev) => [...prev, key]);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const str = reader.result;
|
||||
if (str) {
|
||||
setNewImageHolder((prev) => [...prev, str as string]);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to upload image:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeImage = async (index: number, key: string) => {
|
||||
if (props.postId && props.existingAttachments) {
|
||||
const imgStringArr = props.existingAttachments.split(",");
|
||||
const newString = imgStringArr.filter((str) => str !== key).join(",");
|
||||
|
||||
try {
|
||||
await fetch("/api/trpc/misc.deleteImage", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key,
|
||||
newAttachmentString: newString,
|
||||
type: props.type,
|
||||
id: props.postId
|
||||
})
|
||||
});
|
||||
|
||||
setImageHolder((prev) => prev.filter((_, i) => i !== index));
|
||||
} catch (err) {
|
||||
console.error("Failed to delete image:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeNewImage = async (index: number, key: string) => {
|
||||
setImages((prev) => prev.filter((_, i) => i !== index));
|
||||
setNewImageHolder((prev) => prev.filter((_, i) => i !== index));
|
||||
|
||||
try {
|
||||
await fetch("/api/trpc/misc.simpleDeleteImage", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ key })
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to delete image:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async (key: string) => {
|
||||
try {
|
||||
const bucketString = import.meta.env.VITE_AWS_BUCKET_STRING || "";
|
||||
await navigator.clipboard.writeText(bucketString + key);
|
||||
console.log("Text copied to clipboard");
|
||||
} catch (err) {
|
||||
console.error("Failed to copy text:", err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={props.postTitle}
|
||||
fallback={
|
||||
<div class="text-subtext0 pb-4 text-center italic">
|
||||
Add title to add attachments
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="text-center text-xl">Attachments</div>
|
||||
<div class="flex justify-center">
|
||||
<Dropzone
|
||||
onDrop={handleImageDrop}
|
||||
accept="image/jpg, image/jpeg, image/png"
|
||||
fileHolder={null}
|
||||
preSet={null}
|
||||
/>
|
||||
</div>
|
||||
<div class="-mx-24 grid grid-cols-6 gap-4">
|
||||
<For each={imageHolder()}>
|
||||
{(key, index) => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-crust hover:bg-opacity-80 absolute ml-4 pb-[120px]"
|
||||
onClick={() => removeImage(index(), key)}
|
||||
>
|
||||
<XCircle
|
||||
height={24}
|
||||
width={24}
|
||||
stroke={"currentColor"}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</button>
|
||||
<img src={key} class="mx-4 my-auto h-36 w-36" alt="attachment" />
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="border-surface2 mx-auto border-r" />
|
||||
<For each={newImageHolder()}>
|
||||
{(img, index) => (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-crust hover:bg-opacity-80 absolute ml-4 pb-[120px]"
|
||||
onClick={() =>
|
||||
removeNewImage(index(), newImageHolderKeys()[index()])
|
||||
}
|
||||
>
|
||||
<XCircle
|
||||
height={24}
|
||||
width={24}
|
||||
stroke={"currentColor"}
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
copyToClipboard(newImageHolderKeys()[index()] as string)
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={img}
|
||||
class="mx-4 my-auto h-36 w-36"
|
||||
alt="new attachment"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
}
|
||||
115
src/components/blog/Dropzone.tsx
Normal file
115
src/components/blog/Dropzone.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Show } from "solid-js";
|
||||
|
||||
export interface DropzoneProps {
|
||||
onDrop: (files: File[]) => void;
|
||||
accept?: string;
|
||||
fileHolder?: string | ArrayBuffer | null;
|
||||
preSet?: string | null;
|
||||
}
|
||||
|
||||
export default function Dropzone(props: DropzoneProps) {
|
||||
let inputRef: HTMLInputElement | undefined;
|
||||
|
||||
const handleFileChange = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
const files = target.files;
|
||||
if (files && files.length > 0) {
|
||||
props.onDrop(Array.from(files));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
const files = e.dataTransfer?.files;
|
||||
if (files && files.length > 0) {
|
||||
props.onDrop(Array.from(files));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
class="border-surface2 z-10 my-4 flex border border-dashed bg-transparent shadow-xl"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={handleDragOver}
|
||||
onClick={() => inputRef?.click()}
|
||||
>
|
||||
<label
|
||||
for="upload"
|
||||
class="flex h-48 w-48 cursor-pointer items-center justify-center"
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept={props.accept || "image/jpg, image/jpeg, image/png"}
|
||||
onChange={handleFileChange}
|
||||
multiple
|
||||
/>
|
||||
<Show
|
||||
when={props.fileHolder !== null || props.preSet !== null}
|
||||
fallback={
|
||||
<>
|
||||
<div class="flex flex-col items-center">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="stroke-text h-8 w-8 fill-transparent"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
<span id="drop" class="text-md text-subtext0">
|
||||
Upload Image
|
||||
<br />
|
||||
<span class="text-sm">Click or drag</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<Show
|
||||
when={!props.fileHolder && props.preSet === "userDefault"}
|
||||
fallback={
|
||||
<img
|
||||
src={(props.fileHolder || props.preSet) as string}
|
||||
class="h-36 w-36 rounded-full object-cover object-center"
|
||||
alt="upload"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width={1.5}
|
||||
stroke="currentColor"
|
||||
class="mx-auto h-12 w-12"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z"
|
||||
/>
|
||||
</svg>
|
||||
<span id="drop" class="text-md text-subtext0">
|
||||
Upload Image
|
||||
<br />
|
||||
<span class="text-sm">Click or drag</span>
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
58
src/components/blog/TagMaker.tsx
Normal file
58
src/components/blog/TagMaker.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { For } from "solid-js";
|
||||
import InfoIcon from "~/components/icons/InfoIcon";
|
||||
import Xmark from "~/components/icons/Xmark";
|
||||
|
||||
export interface TagMakerProps {
|
||||
tagInputValue: string;
|
||||
tagHandler: (input: string) => void;
|
||||
tags: string[];
|
||||
deleteTag: (idx: number) => void;
|
||||
}
|
||||
|
||||
export default function TagMaker(props: TagMakerProps) {
|
||||
return (
|
||||
<div class="flex w-full flex-col justify-center md:flex-row md:justify-between">
|
||||
<div class="absolute -mt-12 mb-8 flex w-full justify-center md:mt-0 md:mb-0 md:w-full md:justify-normal">
|
||||
<div class="tooltip md:-ml-8">
|
||||
<div class="md:mt-12">
|
||||
<InfoIcon height={24} width={24} strokeWidth={1} />
|
||||
</div>
|
||||
<div class="tooltip-text -ml-20 w-40">
|
||||
<div class="px-1">start with # end with a space</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="py-4 md:flex md:pt-0">
|
||||
<div class="textarea-group">
|
||||
<input
|
||||
value={props.tagInputValue}
|
||||
onInput={(e) => props.tagHandler(e.currentTarget.value)}
|
||||
name="message"
|
||||
placeholder=" "
|
||||
class="underlinedInput w-full bg-transparent select-text"
|
||||
/>
|
||||
<span class="bar" />
|
||||
<label class="underlinedInputLabel">Tags</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="my-auto flex max-w-[420px] flex-wrap justify-center italic md:justify-start">
|
||||
<For each={props.tags}>
|
||||
{(tag, idx) => (
|
||||
<div class="group bg-mauve relative m-1 h-fit w-fit max-w-[120px] rounded-xl px-2 py-1 text-sm">
|
||||
<div class="overflow-hidden text-base overflow-ellipsis whitespace-nowrap">
|
||||
{tag}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="bg-mantle bg-opacity-50 absolute inset-0 flex items-center justify-center rounded-xl opacity-0 group-hover:opacity-100"
|
||||
onClick={() => props.deleteTag(idx())}
|
||||
>
|
||||
<Xmark strokeWidth={1} color={"white"} height={24} width={24} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
488
src/components/blog/TextEditor.tsx
Normal file
488
src/components/blog/TextEditor.tsx
Normal file
@@ -0,0 +1,488 @@
|
||||
import { Show } from "solid-js";
|
||||
import { createTiptapEditor, useEditorHTML } from "solid-tiptap";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import { Node } from "@tiptap/core";
|
||||
import { createLowlight, common } from "lowlight";
|
||||
import css from "highlight.js/lib/languages/css";
|
||||
import js from "highlight.js/lib/languages/javascript";
|
||||
import ts from "highlight.js/lib/languages/typescript";
|
||||
import ocaml from "highlight.js/lib/languages/ocaml";
|
||||
import rust from "highlight.js/lib/languages/rust";
|
||||
|
||||
// Create lowlight instance with common languages
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
// Register additional languages
|
||||
lowlight.register("css", css);
|
||||
lowlight.register("js", js);
|
||||
lowlight.register("ts", ts);
|
||||
lowlight.register("ocaml", ocaml);
|
||||
lowlight.register("rust", rust);
|
||||
|
||||
// IFrame extension
|
||||
interface IframeOptions {
|
||||
allowFullscreen: boolean;
|
||||
HTMLAttributes: {
|
||||
[key: string]: any;
|
||||
};
|
||||
}
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
iframe: {
|
||||
setIframe: (options: { src: string }) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const IframeEmbed = Node.create<IframeOptions>({
|
||||
name: "iframe",
|
||||
group: "block",
|
||||
atom: true,
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
allowFullscreen: true,
|
||||
HTMLAttributes: {
|
||||
class: "iframe-wrapper"
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
src: {
|
||||
default: null
|
||||
},
|
||||
frameborder: {
|
||||
default: 0
|
||||
},
|
||||
allowfullscreen: {
|
||||
default: this.options.allowFullscreen,
|
||||
parseHTML: () => this.options.allowFullscreen
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: "iframe"
|
||||
}
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["div", this.options.HTMLAttributes, ["iframe", HTMLAttributes]];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setIframe:
|
||||
(options: { src: string }) =>
|
||||
({ tr, dispatch }) => {
|
||||
const { selection } = tr;
|
||||
const node = this.type.create(options);
|
||||
|
||||
if (dispatch) {
|
||||
tr.replaceRangeWith(selection.from, selection.to, node);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export interface TextEditorProps {
|
||||
updateContent: (content: string) => void;
|
||||
preSet?: string;
|
||||
}
|
||||
|
||||
export default function TextEditor(props: TextEditorProps) {
|
||||
let editorRef!: HTMLDivElement;
|
||||
|
||||
const editor = createTiptapEditor(() => ({
|
||||
element: editorRef,
|
||||
extensions: [
|
||||
StarterKit,
|
||||
CodeBlockLowlight.configure({ lowlight }),
|
||||
Link.configure({
|
||||
openOnClick: true
|
||||
}),
|
||||
Image,
|
||||
IframeEmbed
|
||||
],
|
||||
content: props.preSet || `<p><em><b>Hello!</b> World</em></p>`,
|
||||
onUpdate: ({ editor }) => {
|
||||
props.updateContent(editor.getHTML());
|
||||
}
|
||||
}));
|
||||
|
||||
const setLink = () => {
|
||||
const instance = editor();
|
||||
if (!instance) return;
|
||||
|
||||
const previousUrl = instance.getAttributes("link").href;
|
||||
const url = window.prompt("URL", previousUrl);
|
||||
|
||||
if (url === null) return;
|
||||
|
||||
if (url === "") {
|
||||
instance.chain().focus().extendMarkRange("link").unsetLink().run();
|
||||
return;
|
||||
}
|
||||
|
||||
instance
|
||||
.chain()
|
||||
.focus()
|
||||
.extendMarkRange("link")
|
||||
.setLink({ href: url })
|
||||
.run();
|
||||
};
|
||||
|
||||
const addIframe = () => {
|
||||
const instance = editor();
|
||||
if (!instance) return;
|
||||
|
||||
const url = window.prompt("URL");
|
||||
if (url) {
|
||||
instance.commands.setIframe({ src: url });
|
||||
}
|
||||
};
|
||||
|
||||
const addImage = () => {
|
||||
const instance = editor();
|
||||
if (!instance) return;
|
||||
|
||||
const url = window.prompt("URL");
|
||||
if (url) {
|
||||
instance.chain().focus().setImage({ src: url }).run();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="border-surface2 text-text w-full rounded-md border px-4 py-2">
|
||||
<Show when={editor()}>
|
||||
{(instance) => (
|
||||
<>
|
||||
{/* Bubble Menu - appears when text is selected */}
|
||||
<div
|
||||
class="tiptap-bubble-menu"
|
||||
style={{
|
||||
display: "none" // Will be shown by Tiptap when text is selected
|
||||
}}
|
||||
>
|
||||
<div class="bg-mantle text-text mt-4 w-fit rounded p-2 text-sm whitespace-nowrap shadow-lg">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
instance()
|
||||
.chain()
|
||||
.focus()
|
||||
.toggleHeading({ level: 1 })
|
||||
.run()
|
||||
}
|
||||
class={`${
|
||||
instance().isActive("heading", { level: 1 })
|
||||
? "bg-surface2"
|
||||
: "hover:bg-surface1"
|
||||
} rounded px-2 py-1`}
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
instance()
|
||||
.chain()
|
||||
.focus()
|
||||
.toggleHeading({ level: 2 })
|
||||
.run()
|
||||
}
|
||||
class={`${
|
||||
instance().isActive("heading", { level: 2 })
|
||||
? "bg-surface2"
|
||||
: "hover:bg-surface1"
|
||||
} rounded px-2 py-1`}
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
instance()
|
||||
.chain()
|
||||
.focus()
|
||||
.toggleHeading({ level: 3 })
|
||||
.run()
|
||||
}
|
||||
class={`${
|
||||
instance().isActive("heading", { level: 3 })
|
||||
? "bg-surface2"
|
||||
: "hover:bg-surface1"
|
||||
} rounded px-2 py-1`}
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
instance().chain().focus().toggleBold().run()
|
||||
}
|
||||
class={`${
|
||||
instance().isActive("bold")
|
||||
? "bg-crust"
|
||||
: "hover:bg-crust"
|
||||
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
instance().chain().focus().toggleItalic().run()
|
||||
}
|
||||
class={`${
|
||||
instance().isActive("italic")
|
||||
? "bg-crust"
|
||||
: "hover:bg-crust"
|
||||
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
instance().chain().focus().toggleStrike().run()
|
||||
}
|
||||
class={`${
|
||||
instance().isActive("strike")
|
||||
? "bg-crust"
|
||||
: "hover:bg-crust"
|
||||
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
|
||||
>
|
||||
<s>S</s>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={setLink}
|
||||
class={`${
|
||||
instance().isActive("link")
|
||||
? "bg-crust"
|
||||
: "hover:bg-crust"
|
||||
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
|
||||
>
|
||||
Link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
instance().chain().focus().toggleCode().run()
|
||||
}
|
||||
class={`${
|
||||
instance().isActive("code")
|
||||
? "bg-crust"
|
||||
: "hover:bg-crust"
|
||||
} bg-opacity-30 hover:bg-opacity-30 rounded px-2 py-1`}
|
||||
>
|
||||
Code
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolbar - always visible */}
|
||||
<div class="border-surface2 mb-2 flex flex-wrap gap-1 border-b pb-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
instance().chain().focus().toggleHeading({ level: 1 }).run()
|
||||
}
|
||||
class={`${
|
||||
instance().isActive("heading", { level: 1 })
|
||||
? "bg-surface2"
|
||||
: "hover:bg-surface1"
|
||||
} rounded px-2 py-1 text-xs`}
|
||||
title="Heading 1"
|
||||
>
|
||||
H1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
instance().chain().focus().toggleHeading({ level: 2 }).run()
|
||||
}
|
||||
class={`${
|
||||
instance().isActive("heading", { level: 2 })
|
||||
? "bg-surface2"
|
||||
: "hover:bg-surface1"
|
||||
} rounded px-2 py-1 text-xs`}
|
||||
title="Heading 2"
|
||||
>
|
||||
H2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
instance().chain().focus().toggleHeading({ level: 3 }).run()
|
||||
}
|
||||
class={`${
|
||||
instance().isActive("heading", { level: 3 })
|
||||
? "bg-surface2"
|
||||
: "hover:bg-surface1"
|
||||
} rounded px-2 py-1 text-xs`}
|
||||
title="Heading 3"
|
||||
>
|
||||
H3
|
||||
</button>
|
||||
<div class="border-surface2 mx-1 border-l"></div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => instance().chain().focus().toggleBold().run()}
|
||||
class={`${
|
||||
instance().isActive("bold")
|
||||
? "bg-surface2"
|
||||
: "hover:bg-surface1"
|
||||
} rounded px-2 py-1 text-xs`}
|
||||
title="Bold"
|
||||
>
|
||||
<strong>B</strong>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => instance().chain().focus().toggleItalic().run()}
|
||||
class={`${
|
||||
instance().isActive("italic")
|
||||
? "bg-surface2"
|
||||
: "hover:bg-surface1"
|
||||
} rounded px-2 py-1 text-xs`}
|
||||
title="Italic"
|
||||
>
|
||||
<em>I</em>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => instance().chain().focus().toggleStrike().run()}
|
||||
class={`${
|
||||
instance().isActive("strike")
|
||||
? "bg-surface2"
|
||||
: "hover:bg-surface1"
|
||||
} rounded px-2 py-1 text-xs`}
|
||||
title="Strikethrough"
|
||||
>
|
||||
<s>S</s>
|
||||
</button>
|
||||
<div class="border-surface2 mx-1 border-l"></div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
instance().chain().focus().toggleBulletList().run()
|
||||
}
|
||||
class={`${
|
||||
instance().isActive("bulletList")
|
||||
? "bg-surface2"
|
||||
: "hover:bg-surface1"
|
||||
} rounded px-2 py-1 text-xs`}
|
||||
title="Bullet List"
|
||||
>
|
||||
• List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
instance().chain().focus().toggleOrderedList().run()
|
||||
}
|
||||
class={`${
|
||||
instance().isActive("orderedList")
|
||||
? "bg-surface2"
|
||||
: "hover:bg-surface1"
|
||||
} rounded px-2 py-1 text-xs`}
|
||||
title="Ordered List"
|
||||
>
|
||||
1. List
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
instance().chain().focus().toggleBlockquote().run()
|
||||
}
|
||||
class={`${
|
||||
instance().isActive("blockquote")
|
||||
? "bg-surface2"
|
||||
: "hover:bg-surface1"
|
||||
} rounded px-2 py-1 text-xs`}
|
||||
title="Blockquote"
|
||||
>
|
||||
" Quote
|
||||
</button>
|
||||
<div class="border-surface2 mx-1 border-l"></div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
instance().chain().focus().toggleCodeBlock().run()
|
||||
}
|
||||
class={`${
|
||||
instance().isActive("codeBlock")
|
||||
? "bg-surface2"
|
||||
: "hover:bg-surface1"
|
||||
} rounded px-2 py-1 text-xs`}
|
||||
title="Code Block"
|
||||
>
|
||||
{"</>"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={setLink}
|
||||
class={`${
|
||||
instance().isActive("link")
|
||||
? "bg-surface2"
|
||||
: "hover:bg-surface1"
|
||||
} rounded px-2 py-1 text-xs`}
|
||||
title="Add Link"
|
||||
>
|
||||
🔗 Link
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addImage}
|
||||
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
|
||||
title="Add Image"
|
||||
>
|
||||
🖼 Image
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addIframe}
|
||||
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
|
||||
title="Add Iframe"
|
||||
>
|
||||
📺 Iframe
|
||||
</button>
|
||||
<div class="border-surface2 mx-1 border-l"></div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
instance().chain().focus().setHorizontalRule().run()
|
||||
}
|
||||
class="hover:bg-surface1 rounded px-2 py-1 text-xs"
|
||||
title="Horizontal Rule"
|
||||
>
|
||||
─ HR
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<div
|
||||
ref={editorRef}
|
||||
class="prose prose-sm prose-invert sm:prose-base md:prose-xl lg:prose-xl xl:prose-2xl mx-auto min-h-[400px] min-w-full focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user