feat: improve editor

This commit is contained in:
reya
2024-05-07 14:14:21 +07:00
parent afb7c87fa3
commit 437cd71f7e
14 changed files with 1608 additions and 1564 deletions

View File

@ -10,9 +10,8 @@ import { useSlateStatic } from "slate-react";
import { toast } from "sonner"; import { toast } from "sonner";
export function MediaButton({ className }: { className?: string }) { export function MediaButton({ className }: { className?: string }) {
const { ark } = useRouteContext({ strict: false });
const editor = useSlateStatic(); const editor = useSlateStatic();
const { ark } = useRouteContext({ strict: false });
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const uploadToNostrBuild = async () => { const uploadToNostrBuild = async () => {

View File

@ -0,0 +1,83 @@
import { MentionIcon } from "@lume/icons";
import { cn, insertMention } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import * as DropdownMenu from "@radix-ui/react-dropdown-menu";
import { useEffect, useState } from "react";
import { useRouteContext } from "@tanstack/react-router";
import { User } from "@lume/ui";
import { useSlateStatic } from "slate-react";
import type { Contact } from "@lume/types";
import { toast } from "sonner";
export function MentionButton({ className }: { className?: string }) {
const editor = useSlateStatic();
const { ark } = useRouteContext({ strict: false });
const [contacts, setContacts] = useState<string[]>([]);
const select = async (user: string) => {
try {
const metadata = await ark.get_profile(user);
const contact: Contact = { pubkey: user, profile: metadata };
insertMention(editor, contact);
} catch (e) {
toast.error(String(e));
}
};
useEffect(() => {
async function getContacts() {
const data = await ark.get_contact_list();
setContacts(data);
}
getContacts();
}, []);
return (
<DropdownMenu.Root>
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<DropdownMenu.Trigger asChild>
<Tooltip.Trigger asChild>
<button
type="button"
className={cn(
"inline-flex items-center justify-center",
className,
)}
>
<MentionIcon className="size-4" />
</button>
</Tooltip.Trigger>
</DropdownMenu.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Mention
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
<DropdownMenu.Portal>
<DropdownMenu.Content className="flex w-[220px] h-[220px] scrollbar-none flex-col overflow-y-auto rounded-xl bg-black py-1 shadow-md shadow-neutral-500/20 focus:outline-none dark:bg-white">
{contacts.map((contact) => (
<DropdownMenu.Item
key={contact}
onClick={() => select(contact)}
className="shrink-0 h-11 flex items-center hover:bg-white/10 px-2"
>
<User.Provider pubkey={contact}>
<User.Root className="flex items-center gap-2">
<User.Avatar className="shrink-0 size-8 rounded-full" />
<User.Name className="text-sm font-medium text-white dark:text-black" />
</User.Root>
</User.Provider>
</DropdownMenu.Item>
))}
<DropdownMenu.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}

View File

@ -0,0 +1,40 @@
import { NsfwIcon } from "@lume/icons";
import { cn } from "@lume/utils";
import * as Tooltip from "@radix-ui/react-tooltip";
import type { Dispatch, SetStateAction } from "react";
export function PowToggle({
pow,
setPow,
className,
}: {
pow: boolean;
setPow: Dispatch<SetStateAction<boolean>>;
className?: string;
}) {
return (
<Tooltip.Provider>
<Tooltip.Root delayDuration={150}>
<Tooltip.Trigger asChild>
<button
type="button"
onClick={() => setPow((prev) => !prev)}
className={cn(
"inline-flex items-center justify-center",
className,
pow ? "bg-blue-500 text-white" : "",
)}
>
<NsfwIcon className="size-4" />
</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="inline-flex h-7 select-none items-center justify-center rounded-md bg-neutral-950 px-3.5 text-sm text-neutral-50 will-change-[transform,opacity] data-[state=delayed-open]:data-[side=bottom]:animate-slideUpAndFade data-[state=delayed-open]:data-[side=left]:animate-slideRightAndFade data-[state=delayed-open]:data-[side=right]:animate-slideLeftAndFade data-[state=delayed-open]:data-[side=top]:animate-slideDownAndFade dark:bg-neutral-50 dark:text-neutral-950">
Proof of Work
<Tooltip.Arrow className="fill-neutral-950 dark:fill-neutral-50" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
);
}

View File

@ -1,27 +1,18 @@
import { ComposeFilledIcon, TrashIcon } from "@lume/icons"; import { ComposeFilledIcon, TrashIcon } from "@lume/icons";
import { Spinner, User } from "@lume/ui"; import { Spinner } from "@lume/ui";
import { MentionNote } from "@lume/ui/src/note/mentions/note"; import { MentionNote } from "@lume/ui/src/note/mentions/note";
import { import {
Portal,
cn, cn,
insertImage, insertImage,
insertMention,
insertNostrEvent, insertNostrEvent,
isImageUrl, isImageUrl,
sendNativeNotification, sendNativeNotification,
} from "@lume/utils"; } from "@lume/utils";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { nip19 } from "nostr-tools"; import { nip19 } from "nostr-tools";
import { useEffect, useRef, useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { type Descendant, Node, Transforms, createEditor } from "slate";
type Descendant,
Editor,
Node,
Range,
Transforms,
createEditor,
} from "slate";
import { import {
Editable, Editable,
ReactEditor, ReactEditor,
@ -33,6 +24,7 @@ import {
} from "slate-react"; } from "slate-react";
import { MediaButton } from "./-components/media"; import { MediaButton } from "./-components/media";
import { NsfwToggle } from "./-components/nsfw"; import { NsfwToggle } from "./-components/nsfw";
import { MentionButton } from "./-components/mention";
type EditorSearch = { type EditorSearch = {
reply_to: string; reply_to: string;
@ -73,32 +65,20 @@ export const Route = createFileRoute("/editor/")({
}; };
}, },
component: Screen, component: Screen,
pendingComponent: Pending,
}); });
function Screen() { function Screen() {
const ref = useRef<HTMLDivElement | null>();
const { reply_to, quote } = Route.useSearch(); const { reply_to, quote } = Route.useSearch();
const { ark, initialValue, contacts } = Route.useRouteContext(); const { ark, initialValue } = Route.useRouteContext();
const [t] = useTranslation(); const [t] = useTranslation();
const [editorValue, setEditorValue] = useState(initialValue); const [editorValue, setEditorValue] = useState(initialValue);
const [target, setTarget] = useState<Range | undefined>();
const [index, setIndex] = useState(0);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [nsfw, setNsfw] = useState(false); const [nsfw, setNsfw] = useState(false);
const [editor] = useState(() => const [editor] = useState(() =>
withMentions(withNostrEvent(withImages(withReact(createEditor())))), withMentions(withNostrEvent(withImages(withReact(createEditor())))),
); );
const filters =
contacts
?.filter((c) =>
c?.profile.name?.toLowerCase().startsWith(search.toLowerCase()),
)
?.slice(0, 5) ?? [];
const reset = () => { const reset = () => {
// @ts-expect-error, backlog // @ts-expect-error, backlog
editor.children = [{ type: "paragraph", children: [{ text: "" }] }]; editor.children = [{ type: "paragraph", children: [{ text: "" }] }];
@ -138,11 +118,15 @@ function Screen() {
const eventId = await ark.publish(content, reply_to, quote); const eventId = await ark.publish(content, reply_to, quote);
if (eventId) { if (eventId) {
await sendNativeNotification("You've publish new post successfully."); await sendNativeNotification(
"Your note has been published successfully.",
"Lume",
);
} }
// stop loading // stop loading
setLoading(false); setLoading(false);
// reset form // reset form
reset(); reset();
} catch (e) { } catch (e) {
@ -151,58 +135,20 @@ function Screen() {
} }
}; };
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.scrollY + 24}px`;
el.style.left = `${rect.left + window.scrollX}px`;
}
}, [filters.length, editor, index, search, target]);
return ( return (
<div className="flex h-screen w-screen flex-col bg-gradient-to-tr from-neutral-200 to-neutral-100 dark:from-neutral-950 dark:to-neutral-900"> <div className="w-full h-full">
<Slate <Slate editor={editor} initialValue={editorValue}>
editor={editor}
initialValue={editorValue}
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 <div
data-tauri-drag-region data-tauri-drag-region
className="flex h-14 w-full shrink-0 items-center justify-end gap-2 px-2" className="flex h-14 w-full shrink-0 items-center justify-end gap-2 px-2 border-b border-black/10 dark:border-white/10"
> >
<NsfwToggle <NsfwToggle
nsfw={nsfw} nsfw={nsfw}
setNsfw={setNsfw} setNsfw={setNsfw}
className="size-8 rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700" className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20"
/> />
<MediaButton className="size-8 rounded-full bg-neutral-200 hover:bg-neutral-300 dark:bg-neutral-800 dark:hover:bg-neutral-700" /> <MentionButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
<MediaButton className="size-8 rounded-full bg-black/10 hover:bg-black/20 dark:bg-white/10 dark:hover:bg-white/20" />
<button <button
type="button" type="button"
onClick={() => publish()} onClick={() => publish()}
@ -216,10 +162,13 @@ function Screen() {
{t("global.post")} {t("global.post")}
</button> </button>
</div> </div>
<div className="flex h-full min-h-0 w-full"> <div className="flex h-full w-full flex-1 flex-col">
<div className="flex h-full w-full flex-1 flex-col gap-2 px-2 pb-2"> {reply_to && !quote ? (
{reply_to && !quote ? <MentionNote eventId={reply_to} /> : null} <div className="px-4 py-2">
<div className="h-full w-full flex-1 overflow-hidden overflow-y-auto rounded-xl bg-white p-5 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-none dark:ring-1 dark:ring-white/5"> <MentionNote eventId={reply_to} />
</div>
) : null}
<div className="overflow-y-auto p-4">
<Editable <Editable
key={JSON.stringify(editorValue)} key={JSON.stringify(editorValue)}
autoFocus={true} autoFocus={true}
@ -232,37 +181,6 @@ function Screen() {
} }
className="focus:outline-none" className="focus:outline-none"
/> />
{target && filters.length > 0 && (
<Portal>
<div
ref={ref}
className="absolute left-[-9999px] top-[-9999px] z-10 w-[250px] rounded-xl border border-neutral-50 bg-white p-2 shadow-lg dark:border-neutral-900 dark:bg-neutral-950"
>
{filters.map((contact) => (
<button
key={contact.pubkey}
type="button"
onClick={() => {
Transforms.select(editor, target);
insertMention(editor, contact);
setTarget(null);
}}
className="flex w-full flex-col rounded-lg p-2 hover:bg-neutral-100 dark:hover:bg-neutral-900"
>
<User.Provider pubkey={contact.pubkey}>
<User.Root className="flex w-full items-center gap-2">
<User.Avatar className="size-7 shrink-0 rounded-full object-cover" />
<div className="flex w-full flex-col items-start">
<User.Name className="max-w-[8rem] truncate text-sm font-medium" />
</div>
</User.Root>
</User.Provider>
</button>
))}
</div>
</Portal>
)}
</div>
</div> </div>
</div> </div>
</Slate> </Slate>
@ -270,20 +188,6 @@ function Screen() {
); );
} }
function Pending() {
return (
<div
data-tauri-drag-region
className="flex h-full w-full items-center justify-center gap-2.5"
>
<button type="button" disabled>
<Spinner className="size-5" />
</button>
<p>Loading cache...</p>
</div>
);
}
const withNostrEvent = (editor: ReactEditor) => { const withNostrEvent = (editor: ReactEditor) => {
const { insertData, isVoid } = editor; const { insertData, isVoid } = editor;
@ -429,7 +333,7 @@ const Element = (props) => {
return <Event {...props} />; return <Event {...props} />;
default: default:
return ( return (
<p {...attributes} className="text-lg"> <p {...attributes} className="text-[15px]">
{children} {children}
</p> </p>
); );

View File

@ -66,7 +66,7 @@ function Screen() {
<div className="flex items-center justify-center gap-6"> <div className="flex items-center justify-center gap-6">
{loading ? ( {loading ? (
<div className="inline-flex size-6 items-center justify-center"> <div className="inline-flex size-6 items-center justify-center">
<Spinner className="size-6" /> <Spinner className="size-6 text-white" />
</div> </div>
) : ( ) : (
<> <>

View File

@ -276,6 +276,7 @@ export class Ark {
if (quote) { if (quote) {
eventTags.push(["e", replyEvent.id, relayHint, "mention"]); eventTags.push(["e", replyEvent.id, relayHint, "mention"]);
eventTags.push(["q", replyEvent.id]);
} else { } else {
const rootEvent = replyEvent.tags.find((ev) => ev[3] === "root"); const rootEvent = replyEvent.tags.find((ev) => ev[3] === "root");
@ -809,8 +810,8 @@ export class Ark {
label, label,
title: "Editor", title: "Editor",
url, url,
width: 500, width: 560,
height: 360, height: 340,
}); });
} catch (e) { } catch (e) {
throw new Error(String(e)); throw new Error(String(e));

View File

@ -4,21 +4,13 @@ export function MentionIcon(
props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>, props: JSX.IntrinsicAttributes & SVGProps<SVGSVGElement>,
) { ) {
return ( return (
<svg <svg width="24" height="24" fill="none" viewBox="0 0 24 24" {...props}>
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
viewBox="0 0 24 24"
{...props}
>
<path <path
stroke="currentColor" stroke="currentColor"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5" strokeWidth="1.5"
d="M11.85 13.251c-3.719.065-6.427 2.567-7.18 5.915-.13.575.338 1.084.927 1.084h6.901m-.647-6.999l.147-.001c.353 0 .696.022 1.03.064m-1.177-.063a7.889 7.889 0 00-1.852.249m3.028-.186c.334.042.658.104.972.186m-.972-.186a7.475 7.475 0 011.972.524m3.25 1.412v3m0 0v3m0-3h-3m3 0h3m-5.5-11.75a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z" d="M16.868 19.867A9.25 9.25 0 1 1 21.25 12c0 1.98-.984 4.024-3.279 3.816a3.312 3.312 0 0 1-2.978-3.767l.53-3.646m-.585 4.077c-.308 2.188-2.109 3.744-4.023 3.474-1.914-.269-3.217-2.26-2.91-4.448.308-2.187 2.11-3.743 4.023-3.474 1.914.27 3.217 2.26 2.91 4.448Z"
></path> />
</svg> </svg>
); );
} }

View File

@ -23,6 +23,7 @@ export function NoteReply({ large = false }: { large?: boolean }) {
)} )}
> >
<ReplyIcon className="shrink-0 size-4" /> <ReplyIcon className="shrink-0 size-4" />
{large ? "Reply" : null}
</button> </button>
</Tooltip.Trigger> </Tooltip.Trigger>
<Tooltip.Portal> <Tooltip.Portal>

View File

@ -187,9 +187,9 @@ pub async fn publish(
state: State<'_, Nostr>, state: State<'_, Nostr>,
) -> Result<String, String> { ) -> Result<String, String> {
let client = &state.client; let client = &state.client;
let final_tags = tags.into_iter().map(|val| Tag::parse(&val).unwrap()); let event_tags = tags.into_iter().map(|val| Tag::parse(&val).unwrap());
match client.publish_text_note(content, final_tags).await { match client.publish_text_note(content, event_tags).await {
Ok(event_id) => Ok(event_id.to_bech32().unwrap()), Ok(event_id) => Ok(event_id.to_bech32().unwrap()),
Err(err) => Err(err.to_string()), Err(err) => Err(err.to_string()),
} }

View File

@ -1,7 +1,10 @@
use std::path::PathBuf; use std::path::PathBuf;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
use tauri::TitleBarStyle; use tauri::TitleBarStyle;
use tauri::{Manager, Runtime, WebviewUrl, WebviewWindowBuilder}; use tauri::{
utils::config::WindowEffectsConfig, window::Effect, Manager, Runtime, WebviewUrl,
WebviewWindowBuilder,
};
use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::ShellExt;
pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> { pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
@ -60,18 +63,25 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
let _ = let _ =
WebviewWindowBuilder::new(app, "editor-0", WebviewUrl::App(PathBuf::from("editor"))) WebviewWindowBuilder::new(app, "editor-0", WebviewUrl::App(PathBuf::from("editor")))
.title("Editor") .title("Editor")
.min_inner_size(500., 400.) .min_inner_size(560., 340.)
.inner_size(600., 400.) .inner_size(560., 340.)
.hidden_title(true) .hidden_title(true)
.title_bar_style(TitleBarStyle::Overlay) .title_bar_style(TitleBarStyle::Overlay)
.transparent(true)
.effects(WindowEffectsConfig {
state: None,
effects: vec![Effect::WindowBackground],
radius: None,
color: None,
})
.build() .build()
.unwrap(); .unwrap();
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
let _ = let _ =
WebviewWindowBuilder::new(app, "editor-0", WebviewUrl::App(PathBuf::from("editor"))) WebviewWindowBuilder::new(app, "editor-0", WebviewUrl::App(PathBuf::from("editor")))
.title("Editor") .title("Editor")
.min_inner_size(500., 400.) .min_inner_size(560., 340.)
.inner_size(600., 400.) .inner_size(560., 340.)
.build() .build()
.unwrap(); .unwrap();
} }
@ -92,6 +102,13 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
.minimizable(false) .minimizable(false)
.resizable(false) .resizable(false)
.title_bar_style(TitleBarStyle::Overlay) .title_bar_style(TitleBarStyle::Overlay)
.transparent(true)
.effects(WindowEffectsConfig {
state: None,
effects: vec![Effect::WindowBackground],
radius: None,
color: None,
})
.build() .build()
.unwrap(); .unwrap();
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]
@ -131,6 +148,13 @@ pub fn create_tray<R: Runtime>(app: &tauri::AppHandle<R>) -> tauri::Result<()> {
.hidden_title(true) .hidden_title(true)
.resizable(false) .resizable(false)
.minimizable(false) .minimizable(false)
.transparent(true)
.effects(WindowEffectsConfig {
state: None,
effects: vec![Effect::WindowBackground],
radius: None,
color: None,
})
.build() .build()
.unwrap(); .unwrap();
#[cfg(not(target_os = "macos"))] #[cfg(not(target_os = "macos"))]