feat: add editor
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.1.0",
|
||||
"@lume/ark": "workspace:^",
|
||||
"@lume/icons": "workspace:^",
|
||||
"@lume/utils": "workspace:^",
|
||||
@@ -17,10 +18,15 @@
|
||||
"@tauri-apps/api": "2.0.0-alpha.13",
|
||||
"@tauri-apps/plugin-http": "2.0.0-alpha.6",
|
||||
"@tauri-apps/plugin-os": "2.0.0-alpha.6",
|
||||
"framer-motion": "^10.17.0",
|
||||
"jotai": "^2.6.1",
|
||||
"minidenticons": "^4.2.0",
|
||||
"nostr-tools": "~1.17.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"slate": "^0.101.5",
|
||||
"slate-react": "^0.101.5",
|
||||
"sonner": "^1.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
45
packages/ui/src/editor/addMedia.tsx
Normal file
45
packages/ui/src/editor/addMedia.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useArk } from "@lume/ark";
|
||||
import { AddMediaIcon, LoaderIcon } from "@lume/icons";
|
||||
import { useState } from "react";
|
||||
import { useSlateStatic } from "slate-react";
|
||||
import { toast } from "sonner";
|
||||
import { insertImage } from "./utils";
|
||||
|
||||
export function EditorAddMedia() {
|
||||
const ark = useArk();
|
||||
const editor = useSlateStatic();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const uploadToNostrBuild = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const image = await ark.upload({
|
||||
fileExts: ["mp4", "mp3", "webm", "mkv", "avi", "mov"],
|
||||
});
|
||||
|
||||
if (image) {
|
||||
insertImage(editor, image);
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (e) {
|
||||
setLoading(false);
|
||||
toast.error(`Upload failed, error: ${e}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => uploadToNostrBuild()}
|
||||
className="inline-flex items-center justify-center text-sm font-medium rounded-lg size-9 bg-neutral-100 text-neutral-900 hover:bg-neutral-200 dark:bg-neutral-900 dark:text-neutral-100 dark:hover:bg-neutral-800"
|
||||
>
|
||||
{loading ? (
|
||||
<LoaderIcon className="size-5 animate-spin" />
|
||||
) : (
|
||||
<AddMediaIcon className="size-5" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
33
packages/ui/src/editor/column.tsx
Normal file
33
packages/ui/src/editor/column.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { editorAtom } from "@lume/utils";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { EditorForm } from "./form";
|
||||
|
||||
export function Editor() {
|
||||
const isEditorOpen = useAtomValue(editorAtom);
|
||||
|
||||
return (
|
||||
<AnimatePresence initial={false} mode="wait">
|
||||
{isEditorOpen ? (
|
||||
<motion.div
|
||||
key={isEditorOpen ? "editor-open" : "editor-close"}
|
||||
layout
|
||||
initial={{ scale: 0.9, opacity: 0, translateX: -20 }}
|
||||
animate={{
|
||||
scale: [0.95, 1],
|
||||
opacity: [0.5, 1],
|
||||
translateX: [-10, 0],
|
||||
}}
|
||||
exit={{
|
||||
scale: [0.95, 0.9],
|
||||
opacity: [0.5, 0],
|
||||
translateX: [-10, -20],
|
||||
}}
|
||||
className="h-full w-[350px] px-1 pb-1 shrink-0"
|
||||
>
|
||||
<EditorForm />
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
299
packages/ui/src/editor/form.tsx
Normal file
299
packages/ui/src/editor/form.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
import { MentionNote, useStorage } from "@lume/ark";
|
||||
import { TrashIcon } from "@lume/icons";
|
||||
import { NDKCacheUserProfile } from "@lume/types";
|
||||
import { cn, editorValueAtom } from "@lume/utils";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Editor, Range, Transforms, createEditor } from "slate";
|
||||
import {
|
||||
Editable,
|
||||
ReactEditor,
|
||||
Slate,
|
||||
useFocused,
|
||||
useSelected,
|
||||
useSlateStatic,
|
||||
withReact,
|
||||
} from "slate-react";
|
||||
import { User } from "../user";
|
||||
import { EditorAddMedia } from "./addMedia";
|
||||
import {
|
||||
Portal,
|
||||
insertImage,
|
||||
insertMention,
|
||||
insertNostrEvent,
|
||||
isImageUrl,
|
||||
} from "./utils";
|
||||
|
||||
const withNostrEvent = (editor: ReactEditor) => {
|
||||
const { insertData, isVoid } = editor;
|
||||
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "event" ? true : isVoid(element);
|
||||
};
|
||||
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
|
||||
if (text.startsWith("nevent1") || text.startsWith("note1")) {
|
||||
insertNostrEvent(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
}
|
||||
};
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
||||
const withMentions = (editor: ReactEditor) => {
|
||||
const { isInline, isVoid, markableVoid } = editor;
|
||||
|
||||
editor.isInline = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" ? true : isInline(element);
|
||||
};
|
||||
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" ? true : isVoid(element);
|
||||
};
|
||||
|
||||
editor.markableVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "mention" || markableVoid(element);
|
||||
};
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
||||
const withImages = (editor: ReactEditor) => {
|
||||
const { insertData, isVoid } = editor;
|
||||
|
||||
editor.isVoid = (element) => {
|
||||
// @ts-expect-error, wtf
|
||||
return element.type === "image" ? true : isVoid(element);
|
||||
};
|
||||
|
||||
editor.insertData = (data) => {
|
||||
const text = data.getData("text/plain");
|
||||
|
||||
if (isImageUrl(text)) {
|
||||
insertImage(editor, text);
|
||||
} else {
|
||||
insertData(data);
|
||||
}
|
||||
};
|
||||
|
||||
return editor;
|
||||
};
|
||||
|
||||
const Image = ({ attributes, children, element }) => {
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
|
||||
const selected = useSelected();
|
||||
const focused = useFocused();
|
||||
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
<div contentEditable={false} className="relative">
|
||||
<img
|
||||
src={element.url}
|
||||
alt={element.url}
|
||||
className={cn(
|
||||
"object-cover w-full h-auto border rounded-lg border-neutral-100 dark:border-neutral-900 ring-2",
|
||||
selected && focused ? "ring-blue-500" : "ring-transparent",
|
||||
)}
|
||||
contentEditable={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="absolute inline-flex items-center justify-center text-white bg-red-500 rounded-lg top-2 right-2 size-8 hover:bg-red-600"
|
||||
>
|
||||
<TrashIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Mention = ({ attributes, element }) => {
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
|
||||
return (
|
||||
<span
|
||||
{...attributes}
|
||||
type="button"
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="inline-block text-blue-500 align-baseline hover:text-blue-600"
|
||||
>{`@${element.name}`}</span>
|
||||
);
|
||||
};
|
||||
|
||||
const Event = ({ attributes, element, children }) => {
|
||||
const editor = useSlateStatic();
|
||||
const path = ReactEditor.findPath(editor as ReactEditor, element);
|
||||
|
||||
return (
|
||||
<div {...attributes}>
|
||||
{children}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: <explanation> */}
|
||||
<div
|
||||
contentEditable={false}
|
||||
onClick={() => Transforms.removeNodes(editor, { at: path })}
|
||||
className="relative user-select-none"
|
||||
>
|
||||
<MentionNote
|
||||
eventId={element.eventId.replace("nostr:", "")}
|
||||
openable={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Element = (props) => {
|
||||
const { attributes, children, element } = props;
|
||||
|
||||
switch (element.type) {
|
||||
case "image":
|
||||
return <Image {...props} />;
|
||||
case "mention":
|
||||
return <Mention {...props} />;
|
||||
case "event":
|
||||
return <Event {...props} />;
|
||||
default:
|
||||
return (
|
||||
<p {...attributes} className="text-lg">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export function EditorForm() {
|
||||
const storage = useStorage();
|
||||
const ref = useRef<HTMLDivElement | null>();
|
||||
const initialValue = useAtomValue(editorValueAtom);
|
||||
|
||||
const [contacts, setContacts] = useState<NDKCacheUserProfile[]>([]);
|
||||
const [target, setTarget] = useState<Range | undefined>();
|
||||
const [index, setIndex] = useState(0);
|
||||
const [search, setSearch] = useState("");
|
||||
const [editor] = useState(() =>
|
||||
withMentions(withNostrEvent(withImages(withReact(createEditor())))),
|
||||
);
|
||||
|
||||
const filters = contacts
|
||||
?.filter((c) => c?.name?.toLowerCase().startsWith(search.toLowerCase()))
|
||||
?.slice(0, 10);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadContacts() {
|
||||
const res = await storage.getAllCacheUsers();
|
||||
if (res) setContacts(res);
|
||||
}
|
||||
|
||||
loadContacts();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (target && filters.length > 0) {
|
||||
const el = ref.current;
|
||||
const domRange = ReactEditor.toDOMRange(editor, target);
|
||||
const rect = domRange.getBoundingClientRect();
|
||||
el.style.top = `${rect.top + window.pageYOffset + 24}px`;
|
||||
el.style.left = `${rect.left + window.pageXOffset}px`;
|
||||
}
|
||||
}, [filters.length, editor, index, search, target]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col justify-between rounded-xl overflow-hidden bg-white shadow-[rgba(50,_50,_105,_0.15)_0px_2px_5px_0px,_rgba(0,_0,_0,_0.05)_0px_1px_1px_0px] dark:bg-black dark:shadow-[inset_0_0_0.5px_1px_hsla(0,0%,100%,0.075),0_0_0_1px_hsla(0,0%,0%,0.05),0_0.3px_0.4px_hsla(0,0%,0%,0.02),0_0.9px_1.5px_hsla(0,0%,0%,0.045),0_3.5px_6px_hsla(0,0%,0%,0.09)]">
|
||||
<Slate
|
||||
editor={editor}
|
||||
initialValue={initialValue}
|
||||
onChange={() => {
|
||||
const { selection } = editor;
|
||||
|
||||
if (selection && Range.isCollapsed(selection)) {
|
||||
const [start] = Range.edges(selection);
|
||||
const wordBefore = Editor.before(editor, start, { unit: "word" });
|
||||
const before = wordBefore && Editor.before(editor, wordBefore);
|
||||
const beforeRange = before && Editor.range(editor, before, start);
|
||||
const beforeText =
|
||||
beforeRange && Editor.string(editor, beforeRange);
|
||||
const beforeMatch = beforeText?.match(/^@(\w+)$/);
|
||||
const after = Editor.after(editor, start);
|
||||
const afterRange = Editor.range(editor, start, after);
|
||||
const afterText = Editor.string(editor, afterRange);
|
||||
const afterMatch = afterText.match(/^(\s|$)/);
|
||||
|
||||
if (beforeMatch && afterMatch) {
|
||||
setTarget(beforeRange);
|
||||
setSearch(beforeMatch[1]);
|
||||
setIndex(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setTarget(null);
|
||||
}}
|
||||
>
|
||||
<div className="py-6 overflow-y-auto px-7">
|
||||
<Editable
|
||||
autoFocus={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="none"
|
||||
spellCheck={false}
|
||||
renderElement={(props) => <Element {...props} />}
|
||||
placeholder="What are you up to?"
|
||||
className="focus:outline-none"
|
||||
/>
|
||||
{target && filters.length > 0 && (
|
||||
<Portal>
|
||||
<div
|
||||
ref={ref}
|
||||
className="top-[-9999px] left-[-9999px] absolute z-10 w-[250px] p-1 bg-white border border-neutral-50 dark:border-neutral-900 dark:bg-neutral-950 rounded-lg shadow-lg"
|
||||
>
|
||||
{filters.map((contact, i) => (
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: <explanation>
|
||||
<div
|
||||
key={contact.npub}
|
||||
onClick={() => {
|
||||
Transforms.select(editor, target);
|
||||
insertMention(editor, contact);
|
||||
setTarget(null);
|
||||
}}
|
||||
className="px-2 py-2 rounded-md hover:bg-neutral-100 dark:hover:bg-neutral-900"
|
||||
>
|
||||
<User pubkey={contact.npub} variant="simple" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Portal>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between h-16 px-3 border-t border-neutral-100 dark:border-neutral-900 bg-neutral-50 dark:bg-neutral-950">
|
||||
<div />
|
||||
<div className="flex items-center">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<EditorAddMedia />
|
||||
</div>
|
||||
<div className="w-px h-6 mx-3 bg-neutral-200 dark:bg-neutral-800" />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center justify-center w-20 pb-[2px] font-semibold border-t rounded-lg border-neutral-900 dark:border-neutral-800 h-9 bg-neutral-950 text-neutral-50 dark:bg-neutral-900 hover:bg-neutral-900 dark:hover:bg-neutral-800"
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Slate>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
73
packages/ui/src/editor/utils.ts
Normal file
73
packages/ui/src/editor/utils.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { NDKCacheUserProfile } from "@lume/types";
|
||||
import { ReactNode } from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
import { BaseEditor, Transforms } from "slate";
|
||||
import { type ReactEditor } from "slate-react";
|
||||
|
||||
export const Portal = ({ children }: { children?: ReactNode }) => {
|
||||
return typeof document === "object"
|
||||
? ReactDOM.createPortal(children, document.body)
|
||||
: null;
|
||||
};
|
||||
|
||||
export const isImageUrl = (url: string) => {
|
||||
try {
|
||||
if (!url) return false;
|
||||
const ext = new URL(url).pathname.split(".").pop();
|
||||
return ["jpg", "jpeg", "gif", "png", "webp", "avif", "tiff"].includes(ext);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const insertImage = (editor: ReactEditor | BaseEditor, url: string) => {
|
||||
const text = { text: "" };
|
||||
const image = [
|
||||
{
|
||||
type: "image",
|
||||
url,
|
||||
children: [text],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [text],
|
||||
},
|
||||
];
|
||||
|
||||
Transforms.insertNodes(editor, image);
|
||||
};
|
||||
|
||||
export const insertMention = (
|
||||
editor: ReactEditor | BaseEditor,
|
||||
contact: NDKCacheUserProfile,
|
||||
) => {
|
||||
const mention = {
|
||||
type: "mention",
|
||||
npub: `nostr:${contact.npub}`,
|
||||
name: contact.name || contact.displayName || "anon",
|
||||
children: [{ text: "" }],
|
||||
};
|
||||
|
||||
Transforms.insertNodes(editor, mention);
|
||||
Transforms.move(editor);
|
||||
};
|
||||
|
||||
export const insertNostrEvent = (
|
||||
editor: ReactEditor | BaseEditor,
|
||||
eventId: string,
|
||||
) => {
|
||||
const text = { text: "" };
|
||||
const event = [
|
||||
{
|
||||
type: "event",
|
||||
eventId: `nostr:${eventId}`,
|
||||
children: [text],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
children: [text],
|
||||
},
|
||||
];
|
||||
|
||||
Transforms.insertNodes(editor, event);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type Platform } from "@tauri-apps/plugin-os";
|
||||
import { Outlet } from "react-router-dom";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { Editor } from "../editor/column";
|
||||
import { Navigation } from "../navigation";
|
||||
import { WindowTitleBar } from "../titlebar";
|
||||
|
||||
@@ -17,9 +18,10 @@ export function AppLayout({ platform }: { platform: Platform }) {
|
||||
) : (
|
||||
<div data-tauri-drag-region className="h-9 shrink-0" />
|
||||
)}
|
||||
<div className="flex h-full min-h-0 w-full">
|
||||
<div className="flex w-full h-full min-h-0">
|
||||
<Navigation />
|
||||
<div className="h-full flex-1 px-1 pb-1">
|
||||
<Editor />
|
||||
<div className="flex-1 h-full px-1 pb-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,14 +5,18 @@ import {
|
||||
HomeIcon,
|
||||
NwcFilledIcon,
|
||||
NwcIcon,
|
||||
PlusIcon,
|
||||
RelayFilledIcon,
|
||||
RelayIcon,
|
||||
} from "@lume/icons";
|
||||
import { cn } from "@lume/utils";
|
||||
import { cn, editorAtom } from "@lume/utils";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { ActiveAccount } from "./account/active";
|
||||
|
||||
export function Navigation() {
|
||||
const setIsEditorOpen = useSetAtom(editorAtom);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-between w-20 h-full px-4 py-3 shrink-0">
|
||||
<div className="flex flex-col flex-1 gap-5">
|
||||
@@ -154,6 +158,13 @@ export function Navigation() {
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 p-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditorOpen((prev) => !prev)}
|
||||
className="flex items-center justify-center w-full h-auto text-black aspect-square rounded-xl bg-black/10 hover:bg-blue-500 hover:text-white dark:bg-white/10 dark:text-white dark:hover:bg-blue-500"
|
||||
>
|
||||
<PlusIcon className="w-5 h-5" />
|
||||
</button>
|
||||
<ActiveAccount />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user