wip: tiptap editor

This commit is contained in:
Ren Amamiya 2023-07-21 16:16:41 +07:00
parent 8f4cf7e948
commit 64cd17389d
12 changed files with 1155 additions and 330 deletions

View File

@ -25,6 +25,12 @@
"@tanstack/react-query-devtools": "^4.29.25",
"@tanstack/react-virtual": "3.0.0-beta.54",
"@tauri-apps/api": "^1.4.0",
"@tiptap/extension-mention": "^2.0.4",
"@tiptap/extension-placeholder": "^2.0.4",
"@tiptap/pm": "^2.0.4",
"@tiptap/react": "^2.0.4",
"@tiptap/starter-kit": "^2.0.4",
"@tiptap/suggestion": "^2.0.4",
"cheerio": "1.0.0-rc.12",
"dayjs": "^1.11.9",
"destr": "^1.2.2",
@ -48,6 +54,7 @@
"tauri-plugin-autostart-api": "github:tauri-apps/tauri-plugin-autostart#v1",
"tauri-plugin-sql-api": "github:tauri-apps/tauri-plugin-sql",
"tauri-plugin-stronghold-api": "github:tauri-apps/tauri-plugin-stronghold#v1",
"tippy.js": "^6.3.7",
"zustand": "^4.3.9"
},
"devDependencies": {

File diff suppressed because it is too large Load Diff

View File

@ -18,6 +18,14 @@ button {
@apply prose prose-zinc max-w-none select-text break-words dark:prose-invert prose-p:mb-2 prose-p:mt-0 prose-p:last:mb-0 prose-a:break-all prose-a:font-normal prose-a:leading-tight prose-a:text-fuchsia-400 hover:prose-a:text-fuchsia-500 prose-blockquote:m-0 prose-ol:m-0 prose-ol:mb-1 prose-ul:mb-1 prose-li:leading-tight prose-hr:mx-0 prose-hr:my-2;
}
.ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
@apply text-zinc-400;
}
/* For Webkit-based browsers (Chrome, Safari and Opera) */
.scrollbar-hide::-webkit-scrollbar {
display: none;

View File

@ -0,0 +1,99 @@
import Mention from '@tiptap/extension-mention';
import Placeholder from '@tiptap/extension-placeholder';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { twMerge } from 'tailwind-merge';
import { Button } from '@shared/button';
import { Suggestion } from '@shared/composer';
import { CancelIcon } from '@shared/icons';
import { MentionNote } from '@shared/notes';
import { useComposer } from '@stores/composer';
import { FULL_RELAYS } from '@stores/constants';
import { usePublish } from '@utils/hooks/usePublish';
export function Composer() {
const [reply, clearReply, toggle] = useComposer((state) => [
state.reply,
state.clearReply,
state.toggleModal,
]);
const publish = usePublish();
const editor = useEditor({
extensions: [
StarterKit,
Placeholder.configure({ placeholder: "What's on your mind?" }),
Mention.configure({
HTMLAttributes: {
class: 'mention',
},
suggestion: Suggestion,
}),
],
content: '',
editorProps: {
attributes: {
class: twMerge(
'markdown max-h-[500px] overflow-y-auto outline-none',
`${reply.id ? '!min-h-42' : '!min-h-[86px]'}`
),
},
},
});
const submit = async () => {
let tags: string[][] = [];
if (reply.id && reply.pubkey) {
tags = [
['e', reply.id, FULL_RELAYS[0], 'reply'],
['p', reply.pubkey],
];
} else {
tags = [];
}
// serialize content
const serializedContent = editor.getText();
// publish message
// await publish({ content: serializedContent, kind: 1, tags });
// close modal
toggle(false);
};
return (
<div className="flex h-full flex-col px-4 pb-4">
<div className="flex h-full w-full gap-2">
<div className="flex w-8 shrink-0 items-center justify-center">
<div className="h-full w-[2px] bg-zinc-800" />
</div>
<div className="w-full">
<EditorContent editor={editor} />
{reply.id && (
<div className="relative">
<MentionNote id={reply.id} />
<button
type="button"
onClick={() => clearReply()}
className="absolute right-3 top-3 inline-flex h-6 w-6 items-center justify-center gap-2 rounded bg-zinc-800 px-2 hover:bg-zinc-700"
>
<CancelIcon className="h-4 w-4 text-zinc-100" />
</button>
</div>
)}
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<div />
<Button onClick={() => submit()} preset="publish">
Publish
</Button>
</div>
</div>
);
}

View File

@ -1,134 +0,0 @@
import { open } from '@tauri-apps/api/dialog';
import { listen } from '@tauri-apps/api/event';
import { Body, fetch } from '@tauri-apps/api/http';
import { useCallback, useEffect, useState } from 'react';
import { Transforms } from 'slate';
import { useSlateStatic } from 'slate-react';
import { PlusCircleIcon } from '@shared/icons';
import { createBlobFromFile } from '@utils/createBlobFromFile';
export function ImageUploader() {
const editor = useSlateStatic();
const [loading, setLoading] = useState(false);
const insertImage = (editor, url) => {
const image = { type: 'image', url, children: [{ text: url }] };
Transforms.insertNodes(editor, image);
};
const uploadToVoidCat = useCallback(
async (filepath) => {
const filename = filepath.split('/').pop();
const file = await createBlobFromFile(filepath);
const buf = await file.arrayBuffer();
try {
const res: { data: { file: { id: string } } } = await fetch(
'https://void.cat/upload?cli=false',
{
method: 'POST',
timeout: 5,
headers: {
accept: '*/*',
'Content-Type': 'application/octet-stream',
'V-Filename': filename,
'V-Description': 'Uploaded from https://lume.nu',
'V-Strip-Metadata': 'true',
},
body: Body.bytes(buf),
}
);
const image = `https://void.cat/d/${res.data.file.id}.webp`;
// update parent state
insertImage(editor, image);
// reset loading state
setLoading(false);
} catch (error) {
// reset loading state
setLoading(false);
// handle error
if (error instanceof SyntaxError) {
// Unexpected token < in JSON
console.log('There was a SyntaxError', error);
} else {
console.log('There was an error', error);
}
}
},
[editor]
);
const openFileDialog = async () => {
const selected: any = await open({
multiple: false,
filters: [
{
name: 'Image',
extensions: ['png', 'jpeg', 'jpg', 'gif'],
},
],
});
if (Array.isArray(selected)) {
// user selected multiple files
} else if (selected === null) {
// user cancelled the selection
} else {
setLoading(true);
// upload file
uploadToVoidCat(selected);
}
};
useEffect(() => {
async function initFileDrop() {
const unlisten = await listen('tauri://file-drop', (event) => {
// set loading state
setLoading(true);
// upload file
uploadToVoidCat(event.payload[0]);
});
return () => {
unlisten();
};
}
initFileDrop();
}, [uploadToVoidCat]);
return (
<button
type="button"
onClick={() => openFileDialog()}
className="inline-flex h-8 w-8 items-center justify-center rounded hover:bg-zinc-800"
>
{loading ? (
<svg
className="h-4 w-4 animate-spin text-black dark:text-zinc-100"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<title id="loading">Loading</title>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
) : (
<PlusCircleIcon width={20} height={20} className="text-zinc-500" />
)}
</button>
);
}

View File

@ -0,0 +1,5 @@
export * from './user';
export * from './modal';
export * from './composer';
export * from './mention/list';
export * from './mention/suggestion';

View File

@ -0,0 +1,68 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
export const MentionList = forwardRef((props: any, ref: any) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = (index) => {
const item = props.items[index];
if (item) {
props.command({ id: item });
}
};
const upHandler = () => {
setSelectedIndex((selectedIndex + props.items.length - 1) % props.items.length);
};
const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => setSelectedIndex(0), [props.items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (event.key === 'ArrowUp') {
upHandler();
return true;
}
if (event.key === 'ArrowDown') {
downHandler();
return true;
}
if (event.key === 'Enter') {
enterHandler();
return true;
}
return false;
},
}));
return (
<div className="items">
{props.items.length ? (
props.items.map((item, index) => (
<button
className={`item ${index === selectedIndex ? 'is-selected' : ''}`}
key={index}
onClick={() => selectItem(index)}
>
{item}
</button>
))
) : (
<div className="item">No result</div>
)}
</div>
);
});
MentionList.displayName = 'MentionList';

View File

@ -0,0 +1,93 @@
import { ReactRenderer } from '@tiptap/react';
import tippy from 'tippy.js';
import { MentionList } from '@shared/composer';
export const Suggestion = {
items: ({ query }) => {
return [
'Lea Thompson',
'Cyndi Lauper',
'Tom Cruise',
'Madonna',
'Jerry Hall',
'Joan Collins',
'Winona Ryder',
'Christina Applegate',
'Alyssa Milano',
'Molly Ringwald',
'Ally Sheedy',
'Debbie Harry',
'Olivia Newton-John',
'Elton John',
'Michael J. Fox',
'Axl Rose',
'Emilio Estevez',
'Ralph Macchio',
'Rob Lowe',
'Jennifer Grey',
'Mickey Rourke',
'John Cusack',
'Matthew Broderick',
'Justine Bateman',
'Lisa Bonet',
]
.filter((item) => item.toLowerCase().startsWith(query.toLowerCase()))
.slice(0, 5);
},
render: () => {
let component;
let popup;
return {
onStart: (props) => {
component = new ReactRenderer(MentionList, {
props,
editor: props.editor,
});
if (!props.clientRect) {
return;
}
popup = tippy('body', {
getReferenceClientRect: props.clientRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: 'manual',
placement: 'bottom-start',
});
},
onUpdate(props) {
component.updateProps(props);
if (!props.clientRect) {
return;
}
popup[0].setProps({
getReferenceClientRect: props.clientRect,
});
},
onKeyDown(props) {
if (props.event.key === 'Escape') {
popup[0].hide();
return true;
}
return component.ref?.onKeyDown(props);
},
onExit() {
popup[0].destroy();
component.destroy();
},
};
},
};

View File

@ -3,8 +3,7 @@ import { Fragment } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { Button } from '@shared/button';
import { Post } from '@shared/composer/types/post';
import { User } from '@shared/composer/user';
import { Composer, ComposerUser } from '@shared/composer';
import {
CancelIcon,
ChevronDownIcon,
@ -17,9 +16,8 @@ import { COMPOSE_SHORTCUT } from '@stores/shortcuts';
import { useAccount } from '@utils/hooks/useAccount';
export function Composer() {
export function ComposerModal() {
const { account } = useAccount();
const [toggle, open] = useComposer((state) => [state.toggleModal, state.open]);
const closeModal = () => {
@ -60,7 +58,7 @@ export function Composer() {
<Dialog.Panel className="relative h-min w-full max-w-xl rounded-xl border-t border-zinc-800/50 bg-zinc-900">
<div className="flex items-center justify-between px-4 py-4">
<div className="flex items-center gap-2">
<div>{account && <User pubkey={account.pubkey} />}</div>
{account && <ComposerUser pubkey={account.pubkey} />}
<span>
<ChevronRightIcon
width={14}
@ -83,7 +81,7 @@ export function Composer() {
<CancelIcon width={16} height={16} className="text-zinc-500" />
</div>
</div>
{account && <Post />}
<Composer />
</Dialog.Panel>
</Transition.Child>
</div>

View File

@ -1,162 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import { Node, Transforms, createEditor } from 'slate';
import { withHistory } from 'slate-history';
import { Editable, ReactEditor, Slate, useSlateStatic, withReact } from 'slate-react';
import { Button } from '@shared/button';
import { ImageUploader } from '@shared/composer/imageUploader';
import { CancelIcon, TrashIcon } from '@shared/icons';
import { MentionNote } from '@shared/notes/mentions/note';
import { useComposer } from '@stores/composer';
import { FULL_RELAYS } from '@stores/constants';
import { usePublish } from '@utils/hooks/usePublish';
const withImages = (editor) => {
const { isVoid } = editor;
editor.isVoid = (element) => {
return element.type === 'image' ? true : isVoid(element);
};
return editor;
};
const ImagePreview = ({
attributes,
children,
element,
}: {
attributes: any;
children: any;
element: any;
}) => {
const editor: any = useSlateStatic();
const path = ReactEditor.findPath(editor, element);
return (
<figure {...attributes} className="m-0 mt-3">
{children}
<div contentEditable={false} className="relative">
<img
alt={element.url}
src={element.url}
className="m-0 h-auto max-h-[300px] w-full rounded-md object-cover"
/>
<button
type="button"
onClick={() => Transforms.removeNodes(editor, { at: path })}
className="shadow-mini-button absolute right-2 top-2 inline-flex h-7 w-7 items-center justify-center gap-0.5 rounded bg-zinc-800 text-base font-medium text-zinc-400 hover:bg-zinc-700"
>
<TrashIcon width={14} height={14} className="text-zinc-100" />
</button>
</div>
</figure>
);
};
export function Post() {
const publish = usePublish();
const editor = useMemo(() => withReact(withImages(withHistory(createEditor()))), []);
const [reply, clearReply, toggle] = useComposer((state) => [
state.reply,
state.clearReply,
state.toggleModal,
]);
const [content, setContent] = useState<Node[]>([
{
children: [
{
text: '',
},
],
},
]);
const serialize = useCallback((nodes: Node[]) => {
return nodes.map((n) => Node.string(n)).join('\n');
}, []);
const removeReply = () => {
clearReply();
};
const submit = async () => {
let tags: string[][] = [];
if (reply.id && reply.pubkey) {
tags = [
['e', reply.id, FULL_RELAYS[0], 'reply'],
['p', reply.pubkey],
];
} else {
tags = [];
}
// serialize content
const serializedContent = serialize(content);
// publish message
await publish({ content: serializedContent, kind: 1, tags });
// close modal
toggle(false);
};
const renderElement = useCallback((props) => {
switch (props.element.type) {
case 'image':
if (props.element.url) {
return <ImagePreview {...props} />;
}
break;
default:
return <p {...props.attributes}>{props.children}</p>;
}
}, []);
return (
<Slate editor={editor} value={content} onChange={setContent}>
<div className="flex h-full flex-col px-4 pb-4">
<div className="flex h-full w-full gap-2">
<div className="flex w-8 shrink-0 items-center justify-center">
<div className="h-full w-[2px] bg-zinc-800" />
</div>
<div className="w-full">
<Editable
placeholder={
reply.id ? 'Share your thoughts on it' : "What's on your mind?"
}
spellCheck="false"
className={`${
reply.id ? '!min-h-42' : '!min-h-[86px]'
} markdown max-h-[500px] overflow-y-auto`}
renderElement={renderElement}
/>
{reply.id && (
<div className="relative">
<MentionNote id={reply.id} />
<button
type="button"
onClick={() => removeReply()}
className="absolute right-3 top-3 inline-flex h-6 w-max items-center justify-center gap-2 rounded bg-zinc-800 px-2 hover:bg-zinc-700"
>
<CancelIcon className="h-4 w-4 text-zinc-100" />
<span className="text-sm">Stop reply</span>
</button>
</div>
)}
</div>
</div>
<div className="mt-4 flex items-center justify-between">
<ImageUploader />
<Button onClick={() => submit()} preset="publish">
Publish
</Button>
</div>
</div>
</Slate>
);
}

View File

@ -4,7 +4,7 @@ import { DEFAULT_AVATAR } from '@stores/constants';
import { useProfile } from '@utils/hooks/useProfile';
export function User({ pubkey }: { pubkey: string }) {
export function ComposerUser({ pubkey }: { pubkey: string }) {
const { user } = useProfile(pubkey);
return (

View File

@ -5,7 +5,7 @@ import { twMerge } from 'tailwind-merge';
import { ChatsList } from '@app/chat/components/list';
import { AppHeader } from '@shared/appHeader';
import { Composer } from '@shared/composer/modal';
import { ComposerModal } from '@shared/composer/modal';
import { NavArrowDownIcon, SpaceIcon, TrendingIcon } from '@shared/icons';
import { LumeBar } from '@shared/lumeBar';
@ -15,7 +15,7 @@ export function Navigation() {
<AppHeader />
<div className="scrollbar-hide flex flex-col gap-5 overflow-y-auto pb-20">
<div className="inlin-lflex h-8 px-3.5">
<Composer />
<ComposerModal />
</div>
{/* Newsfeed */}
<div className="flex flex-col gap-0.5 px-1.5">