Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/frontend/src/components/NoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { useAuth } from '../auth';
import { maxUploadBytes, preparedUpload } from '../files';
import type { Item } from '../spaces';
import { colorFor, useLiveDocument, useLiveStatus, type Connection } from './live-document';
import { internalPath, mentionExtension, StoredImageExtension } from './note-extensions';
import { blockMenuExtension, internalPath, mentionExtension, StoredImageExtension } from './note-extensions';

function Toolbar({ editor }: { editor: Editor }) {
const state = useEditorState({
Expand Down Expand Up @@ -68,6 +68,7 @@ function CollaborativeEditor({ connection, editable, noteId, spaceId }: { connec
Collaboration.configure({ document: connection.document }),
CollaborationCaret.configure({ provider: connection.provider, user: { name: user.displayName, color: colorFor(user.id) } }),
mentionExtension(auth.request, noteId),
blockMenuExtension(insertImages),
StoredImageExtension,
],
editorProps: {
Expand Down
149 changes: 104 additions & 45 deletions app/frontend/src/components/note-extensions.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,28 @@
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react';
import { Extension, type ChainedCommands, type Editor } from '@tiptap/core';
import Image from '@tiptap/extension-image';
import Mention from '@tiptap/extension-mention';
import { NodeViewWrapper, ReactNodeViewRenderer, ReactRenderer, type NodeViewProps } from '@tiptap/react';
import type { SuggestionKeyDownProps, SuggestionProps } from '@tiptap/suggestion';
import Suggestion, { type SuggestionKeyDownProps, type SuggestionOptions, type SuggestionProps } from '@tiptap/suggestion';
import { readBlob } from '../api';
import { useAuth } from '../auth';
import { itemHref } from '../spaces';

export interface MentionCandidate {
interface Candidate {
id: string;
label: string;
href: string;
hint: string;
}

export interface MentionCandidate extends Candidate {
href: string;
}

interface BlockChoice extends Candidate {
words: string;
run: (chain: ChainedCommands, editor: Editor) => void;
}

interface SearchReply {
items: { id: string; spaceId: string; spaceName: string; kind: string; title: string }[];
}
Expand All @@ -24,30 +33,30 @@ interface ListHandle {
onKeyDown: (props: SuggestionKeyDownProps) => boolean;
}

const MentionList = forwardRef<ListHandle, SuggestionProps<MentionCandidate>>(function MentionList({ command, items }, ref) {
const SuggestionList = forwardRef<ListHandle, SuggestionProps<Candidate>>(function SuggestionList({ command, items }, ref) {
const [active, setActive] = useState(0);
useEffect(() => setActive(0), [items]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }) => {
if (items.length === 0) return false;
if (event.key === 'ArrowDown') setActive((current) => (current + 1) % items.length);
else if (event.key === 'ArrowUp') setActive((current) => (current - 1 + items.length) % items.length);
else if (event.key === 'Enter') command({ id: items[active]!.id, label: items[active]!.label, href: items[active]!.href } as never);
else if (event.key === 'Enter') command(items[active]!);
else return false;
return true;
},
}));
return (
<div className="mention-menu" role="listbox">
<div className="suggestion-menu" role="listbox">
{items.length === 0 ? (
<p className="m-0 px-3 py-2 text-sm text-muted">Nothing matches</p>
) : (
items.map((item, index) => (
<button
aria-selected={index === active}
className="mention-option"
className="suggestion-option"
key={item.id}
onClick={() => command({ id: item.id, label: item.label, href: item.href } as never)}
onClick={() => command(item)}
onMouseEnter={() => setActive(index)}
role="option"
type="button"
Expand All @@ -66,6 +75,42 @@ export const internalPath = (href: unknown) => (typeof href === 'string' && /^\/
export const storedFileId = (fileId: unknown) =>
typeof fileId === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(fileId) ? fileId : null;

function suggestionPopup<Item extends Candidate>(): ReturnType<NonNullable<SuggestionOptions<Item>['render']>> {
let renderer: ReactRenderer<ListHandle, SuggestionProps<Candidate>> | undefined;
const place = (props: SuggestionProps<Item>) => {
const box = props.clientRect?.();
const element = renderer?.element as HTMLElement | undefined;
if (!box || !element) return;
element.style.position = 'fixed';
element.style.left = `${box.left}px`;
element.style.top = `${box.bottom + 6}px`;
element.style.zIndex = '50';
};
const close = () => {
renderer?.destroy();
renderer?.element.remove();
renderer = undefined;
};
return {
onStart: (props) => {
const shown = new ReactRenderer(SuggestionList, { props: props as SuggestionProps<Candidate>, editor: props.editor });
renderer = shown;
document.body.append(shown.element);
place(props);
},
onUpdate: (props) => {
renderer?.updateProps(props as SuggestionProps<Candidate>);
place(props);
},
onKeyDown: (props) => {
if (props.event.key !== 'Escape') return renderer?.ref?.onKeyDown(props) ?? false;
close();
return true;
},
onExit: close,
};
}

export function mentionExtension(request: Request, noteId: string) {
return Mention.extend({
addAttributes() {
Expand All @@ -91,43 +136,7 @@ export function mentionExtension(request: Request, noteId: string) {
.slice(0, 8)
.map((item) => ({ id: item.id, label: item.title || 'Untitled', href: itemHref(item) ?? `/spaces/${item.spaceId}`, hint: `${item.kind} · ${item.spaceName}` }));
},
render: () => {
let renderer: ReactRenderer<ListHandle, SuggestionProps<MentionCandidate>> | undefined;
const place = (props: SuggestionProps<MentionCandidate>) => {
const box = props.clientRect?.();
const element = renderer?.element as HTMLElement | undefined;
if (!box || !element) return;
element.style.position = 'fixed';
element.style.left = `${box.left}px`;
element.style.top = `${box.bottom + 6}px`;
element.style.zIndex = '50';
};
return {
onStart: (props) => {
renderer = new ReactRenderer(MentionList, { props, editor: props.editor });
document.body.append(renderer.element);
place(props);
},
onUpdate: (props) => {
renderer?.updateProps(props);
place(props);
},
onKeyDown: (props) => {
if (props.event.key === 'Escape') {
renderer?.destroy();
renderer?.element.remove();
renderer = undefined;
return true;
}
return renderer?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
renderer?.destroy();
renderer?.element.remove();
renderer = undefined;
},
};
},
render: suggestionPopup,
},
});
}
Expand Down Expand Up @@ -184,3 +193,53 @@ export const StoredImageExtension = Image.extend({
return ReactNodeViewRenderer(StoredImage);
},
});

export function blockMenuExtension(onImages: (view: Editor['view'], files: File[]) => void) {
const chooseImages = (editor: Editor) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.multiple = true;
input.onchange = () => onImages(editor.view, [...(input.files ?? [])]);
input.click();
};
const choices: BlockChoice[] = [
{ id: 'text', label: 'Text', hint: 'paragraph', words: 'text paragraph plain', run: (chain) => chain.setParagraph().run() },
{ id: 'heading', label: 'Heading', hint: '#', words: 'heading title h1', run: (chain) => chain.setHeading({ level: 1 }).run() },
{ id: 'subheading', label: 'Subheading', hint: '##', words: 'subheading heading h2', run: (chain) => chain.setHeading({ level: 2 }).run() },
{ id: 'bullets', label: 'Bulleted list', hint: '-', words: 'bulleted list bullets unordered', run: (chain) => chain.toggleBulletList().run() },
{ id: 'numbers', label: 'Numbered list', hint: '1.', words: 'numbered list ordered numbers', run: (chain) => chain.toggleOrderedList().run() },
{ id: 'checklist', label: 'Checklist', hint: '[]', words: 'checklist todo tasks checkbox', run: (chain) => chain.toggleTaskList().run() },
{ id: 'quote', label: 'Quote', hint: '>', words: 'quote blockquote', run: (chain) => chain.toggleBlockquote().run() },
{ id: 'code', label: 'Code block', hint: '```', words: 'code block snippet', run: (chain) => chain.toggleCodeBlock().run() },
{ id: 'divider', label: 'Divider', hint: '---', words: 'divider line separator rule', run: (chain) => chain.setHorizontalRule().run() },
{
id: 'image',
label: 'Image',
hint: 'upload',
words: 'image picture photo upload',
run: (chain, editor) => {
chain.run();
chooseImages(editor);
},
},
{ id: 'link', label: 'Link to a page', hint: '@', words: 'link page mention reference', run: (chain) => chain.insertContent('@').run() },
];
return Extension.create({
name: 'blockMenu',
addProseMirrorPlugins() {
return [
Suggestion<BlockChoice>({
editor: this.editor,
char: '/',
items: ({ query }) => {
const wanted = query.trim().toLowerCase();
return choices.filter((choice) => choice.label.toLowerCase().includes(wanted) || choice.words.includes(wanted));
},
command: ({ editor, range, props }) => props.run(editor.chain().focus().deleteRange(range), editor),
render: suggestionPopup,
}),
];
},
});
}
6 changes: 3 additions & 3 deletions app/frontend/src/styles/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@
white-space: nowrap;
}

.mention-menu {
.suggestion-menu {
display: grid;
width: min(22rem, calc(100vw - 2rem));
max-height: 18rem;
Expand All @@ -193,7 +193,7 @@
box-shadow: var(--shadow-popover);
}

.mention-option {
.suggestion-option {
display: flex;
align-items: center;
justify-content: space-between;
Expand All @@ -205,7 +205,7 @@
text-align: left;
}

.mention-option[aria-selected='true'] {
.suggestion-option[aria-selected='true'] {
background: var(--color-line-soft);
}

Expand Down
76 changes: 76 additions & 0 deletions app/frontend/test/block-menu.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import type { Editor } from '@tiptap/core';
import { TaskItem, TaskList } from '@tiptap/extension-list';
import { EditorContent, useEditor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { blockMenuExtension } from '../src/components/note-extensions';

(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;

describe('The / block menu', () => {
let editor: Editor | undefined;
let root: Root | undefined;

afterEach(() => {
act(() => root?.unmount());
document.body.innerHTML = '';
});

async function typeIn(text: string) {
await act(async () => {
editor!.commands.insertContent(text);
});
}

const options = () => [...document.querySelectorAll('.suggestion-menu [role="option"]')].map((option) => option.textContent);

async function open(onImages = vi.fn()) {
function Page() {
const created = useEditor({ extensions: [StarterKit, TaskList, TaskItem, blockMenuExtension(onImages)], content: '<p></p>', immediatelyRender: true });
editor = created ?? undefined;
return <EditorContent editor={created} />;
}
const element = document.createElement('div');
document.body.append(element);
root = createRoot(element);
await act(async () => root!.render(<Page />));
await act(async () => {
editor!.commands.focus('end');
});
return onImages;
}

it('filters blocks as you type and turns the line into the one you pick', async () => {
await open();
await typeIn('/');
expect(options()).toContain('Heading#');
expect(options()).toContain('Divider---');

await typeIn('todo');
expect(options()).toEqual(['Checklist[]']);
await act(async () => document.querySelector<HTMLButtonElement>('.suggestion-menu [role="option"]')!.click());

expect(editor!.getJSON().content?.[0]?.type).toBe('taskList');
expect(editor!.getText()).not.toContain('/todo');
expect(document.querySelector('.suggestion-menu')).toBeNull();
});

it('starts a page link and asks for images through the editor', async () => {
const onImages = await open();
await typeIn('/link');
await act(async () => document.querySelector<HTMLButtonElement>('.suggestion-menu [role="option"]')!.click());
expect(editor!.getText()).toBe('@');

const click = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {});
await typeIn(' /image');
await act(async () => document.querySelector<HTMLButtonElement>('.suggestion-menu [role="option"]')!.click());
expect(click).toHaveBeenCalledTimes(1);
const picker = click.mock.contexts[0] as HTMLInputElement;
expect(picker.accept).toBe('image/*');
picker.onchange?.(new Event('change'));
expect(onImages).toHaveBeenCalledWith(editor!.view, []);
click.mockRestore();
});
});
Loading