diff --git a/.vscode/settings.json b/.vscode/settings.json index cd57e67..6657a0d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,7 @@ { "editor.formatOnSave": true, "cSpell.words": [ + "fuzzysort", "lowlight", "smilie" ] diff --git a/package-lock.json b/package-lock.json index 37279f8..8b6703e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,8 +27,10 @@ "@tiptap/extension-underline": "^2.0.0-beta.23", "@tiptap/react": "^2.0.0-beta.109", "@tiptap/starter-kit": "^2.0.0-beta.184", + "@tiptap/suggestion": "^2.0.0-beta.91", "date-fns": "^2.28.0", "file-saver": "^2.0.5", + "fuzzysort": "^1.9.0", "lodash": "^4.17.21", "lowlight": "^2.6.1", "react": "^17.0.2", @@ -3235,6 +3237,23 @@ "url": "https://github.com/sponsors/ueberdosis" } }, + "node_modules/@tiptap/suggestion": { + "version": "2.0.0-beta.91", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.0.0-beta.91.tgz", + "integrity": "sha512-G3TGwEsDadDL9eqHBI6PI3wczneEQ/P3JFeAvsPuSLlAo4ub9hXjoQz5KWwblLIH1xEQ0TgCPzITsdiF/e3XdQ==", + "dependencies": { + "prosemirror-model": "^1.16.1", + "prosemirror-state": "^1.3.4", + "prosemirror-view": "^1.23.6" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.0.0-beta.1" + } + }, "node_modules/@types/body-parser": { "version": "1.19.2", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", @@ -7177,6 +7196,11 @@ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "dev": true }, + "node_modules/fuzzysort": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-1.9.0.tgz", + "integrity": "sha512-MOxCT0qLTwLqmEwc7UtU045RKef7mc8Qz8eR4r2bLNEq9dy/c3ZKMEFp6IEst69otkQdFZ4FfgH2dmZD+ddX1g==" + }, "node_modules/gauge": { "version": "2.7.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", @@ -15129,6 +15153,16 @@ "@tiptap/extension-text": "^2.0.0-beta.15" } }, + "@tiptap/suggestion": { + "version": "2.0.0-beta.91", + "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-2.0.0-beta.91.tgz", + "integrity": "sha512-G3TGwEsDadDL9eqHBI6PI3wczneEQ/P3JFeAvsPuSLlAo4ub9hXjoQz5KWwblLIH1xEQ0TgCPzITsdiF/e3XdQ==", + "requires": { + "prosemirror-model": "^1.16.1", + "prosemirror-state": "^1.3.4", + "prosemirror-view": "^1.23.6" + } + }, "@types/body-parser": { "version": "1.19.2", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.2.tgz", @@ -18248,6 +18282,11 @@ "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", "dev": true }, + "fuzzysort": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/fuzzysort/-/fuzzysort-1.9.0.tgz", + "integrity": "sha512-MOxCT0qLTwLqmEwc7UtU045RKef7mc8Qz8eR4r2bLNEq9dy/c3ZKMEFp6IEst69otkQdFZ4FfgH2dmZD+ddX1g==" + }, "gauge": { "version": "2.7.4", "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", diff --git a/package.json b/package.json index 923f54a..21be19a 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,10 @@ "@tiptap/extension-underline": "^2.0.0-beta.23", "@tiptap/react": "^2.0.0-beta.109", "@tiptap/starter-kit": "^2.0.0-beta.184", + "@tiptap/suggestion": "^2.0.0-beta.91", "date-fns": "^2.28.0", "file-saver": "^2.0.5", + "fuzzysort": "^1.9.0", "lodash": "^4.17.21", "lowlight": "^2.6.1", "react": "^17.0.2", diff --git a/src/pages/Newtab/components/editor/Tiptap.tsx b/src/pages/Newtab/components/editor/Tiptap.tsx index 4999524..5724d68 100644 --- a/src/pages/Newtab/components/editor/Tiptap.tsx +++ b/src/pages/Newtab/components/editor/Tiptap.tsx @@ -13,7 +13,7 @@ import Typography from '@tiptap/extension-typography'; import './Tiptap.scss' import Menubar from './Menubar' -import { SearchAndReplace, SmilieReplacer } from './extensions' +import { suggestions, Commands, SearchAndReplace, SmilieReplacer } from './extensions' import Table from '@tiptap/extension-table'; import TableCell from '@tiptap/extension-table-cell'; import TableHeader from '@tiptap/extension-table-header'; @@ -94,7 +94,8 @@ const Tiptap = ({ onUpdate, content, isNoteInBin }: TiptapProps) => { linkOnPaste: true, autolink: true, }), - SmilieReplacer + SmilieReplacer, + Commands.configure({ suggestions }) ], content: content, onUpdate: ({ editor }) => onUpdate(editor.getHTML(), editor.getText()), diff --git a/src/pages/Newtab/components/editor/extensions/CodeBlockLowLight.ts b/src/pages/Newtab/components/editor/extensions/CodeBlockLowLight.ts index 4eb2989..dc95007 100644 --- a/src/pages/Newtab/components/editor/extensions/CodeBlockLowLight.ts +++ b/src/pages/Newtab/components/editor/extensions/CodeBlockLowLight.ts @@ -42,9 +42,7 @@ function parseNodes(nodes: any[], className: string[] = []): { text: string, cla : [], ] - if (node.children) { - return parseNodes(node.children, classes) - } + if (node.children) return parseNodes(node.children, classes) return { text: node.value, diff --git a/src/pages/Newtab/components/editor/extensions/index.ts b/src/pages/Newtab/components/editor/extensions/index.ts index ee0d1c9..b9abf97 100644 --- a/src/pages/Newtab/components/editor/extensions/index.ts +++ b/src/pages/Newtab/components/editor/extensions/index.ts @@ -1,2 +1,3 @@ export * from './searchAndReplace' export * from './smilieReplacer' +export * from './slash-menu' diff --git a/src/pages/Newtab/components/editor/extensions/slash-menu/CommandList.tsx b/src/pages/Newtab/components/editor/extensions/slash-menu/CommandList.tsx new file mode 100644 index 0000000..4c9b863 --- /dev/null +++ b/src/pages/Newtab/components/editor/extensions/slash-menu/CommandList.tsx @@ -0,0 +1,86 @@ +import React, { useEffect, useState } from 'react' +import { stopPrevent } from '../../../../utils' + +import './styles/CommandList.scss' + +interface CommandListProps { + items: any[], + command: Function, + event: any +} + +export const CommandList: React.FC = ({ items, command, event }) => { + const [selectedIndex, setSelectedIndex] = useState(0) + + useEffect(() => setSelectedIndex(0), [items]) + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'ArrowUp') { + stopPrevent(event) + upHandler() + return true + } + + if (event.key === 'ArrowDown') { + stopPrevent(event) + downHandler() + return true + } + + if (event.key === 'Enter') { + stopPrevent(event) + enterHandler() + return true + } + + return false + } + + useEffect(() => { onKeyDown(event) }, [event]) + + const upHandler = () => { + setSelectedIndex(((selectedIndex + items.length) - 1) % items.length) + } + + const downHandler = () => { + setSelectedIndex((selectedIndex + 1) % items.length) + } + + const enterHandler = () => { + selectItem(selectedIndex) + } + + const selectItem = (index: number) => { + const item = items[index] + + if (item) setTimeout(() => command(item)) + } + + return ( +
+ { + items.length + ? ( + <> + { + items.map((item, index) => { + return ( +
selectItem(index)} + > + + {item.icon()} + + {item.shortcut && {item.shortcut}} +
+ ) + }) + } + + ) :
No result
+ } +
+ ) +} diff --git a/src/pages/Newtab/components/editor/extensions/slash-menu/command.ts b/src/pages/Newtab/components/editor/extensions/slash-menu/command.ts new file mode 100644 index 0000000..3239587 --- /dev/null +++ b/src/pages/Newtab/components/editor/extensions/slash-menu/command.ts @@ -0,0 +1,24 @@ +import { Extension } from '@tiptap/core' +import Suggestion from '@tiptap/suggestion' + +export const Commands = Extension.create({ + name: 'commands', + + addOptions() { + return { + suggestions: { + char: '/', + command: ({ editor, range, props } : any) => props.command({ editor, range }), + }, + } + }, + + addProseMirrorPlugins() { + return [ + Suggestion({ + editor: this.editor, + ...this.options.suggestions, + }), + ] + }, +}) diff --git a/src/pages/Newtab/components/editor/extensions/slash-menu/index.ts b/src/pages/Newtab/components/editor/extensions/slash-menu/index.ts new file mode 100644 index 0000000..0d2a33c --- /dev/null +++ b/src/pages/Newtab/components/editor/extensions/slash-menu/index.ts @@ -0,0 +1,3 @@ +export * from './CommandList' +export * from './command' +export * from './suggestions' diff --git a/src/pages/Newtab/components/editor/extensions/slash-menu/styles/CommandList.scss b/src/pages/Newtab/components/editor/extensions/slash-menu/styles/CommandList.scss new file mode 100644 index 0000000..7af65f1 --- /dev/null +++ b/src/pages/Newtab/components/editor/extensions/slash-menu/styles/CommandList.scss @@ -0,0 +1,29 @@ +.items { + position: relative; + border-radius: var(--nextui-radii-sm); + background: var(--nextui-colors-background); + color: var(--nextui-colors-text); + box-shadow: var(--nextui-shadows-md); + padding: 6px; + box-sizing: border-box; +} + +.item { + width: 100%; + border-radius: 0.4rem; + align-items: center; + gap: 8px; + padding: 6px 8px; + box-sizing: border-box; + justify-content: space-between; + + svg { + display: inline-flex; + justify-content: center; + align-items: center; + } + + &.is-selected { + background-color: var(--nextui-colors-selection); + } +} diff --git a/src/pages/Newtab/components/editor/extensions/slash-menu/suggestions.ts b/src/pages/Newtab/components/editor/extensions/slash-menu/suggestions.ts new file mode 100644 index 0000000..91816d8 --- /dev/null +++ b/src/pages/Newtab/components/editor/extensions/slash-menu/suggestions.ts @@ -0,0 +1,251 @@ +// @ts-nocheck +import tippy from 'tippy.js' +import { ReactRenderer } from '@tiptap/react' +import { RiDoubleQuotesL, RiH1, RiH2, RiH3, RiBold, RiItalic, RiUnderline, RiStrikethrough, RiCodeBoxLine, RiCodeSSlashLine, RiListOrdered, RiListUnordered, RiListCheck2 } from 'react-icons/ri' +import fuzzysort from 'fuzzysort' + +import { CommandList } from './CommandList' +import { stopPrevent } from '../../../../utils' + +const SlashMenuItems = [ + { + title: 'Heading 1', + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .setNode('heading', { level: 1 }) + .run() + }, + icon: RiH1, + shortcut: '#' + }, + { + title: 'Heading 2', + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .setNode('heading', { level: 2 }) + .run() + }, + icon: RiH2, + shortcut: '##' + }, + { + title: 'Heading 3', + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .setNode('heading', { level: 3 }) + .run() + }, + icon: RiH3, + shortcut: '###' + }, + { + title: 'Ordered List', + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .toggleOrderedList() + .run() + }, + icon: RiListOrdered, + shortcut: '1. L' + }, + { + title: 'Bullet List', + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .toggleBulletList() + .run() + }, + icon: RiListUnordered, + shortcut: '- L' + }, + { + title: 'Task List', + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .toggleTaskList() + .run() + }, + icon: RiListCheck2, + }, + { + title: 'Bold', + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .setMark('bold') + .run() + }, + icon: RiBold, + shortcut: '**b**' + }, + { + title: 'Italic', + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .setMark('italic') + .run() + }, + icon: RiItalic, + shortcut: '_i_' + }, + { + title: 'Underline', + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .setMark('underline') + .run() + }, + icon: RiUnderline, + }, + { + title: 'Strike', + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .setMark('strike') + .run() + }, + icon: RiStrikethrough, + shortcut: '~~s~~' + }, + { + title: 'Code', + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .setMark('code') + .run() + }, + icon: RiCodeSSlashLine, + shortcut: '`i`' + }, + { + title: 'Code Block', + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .setCodeBlock({ language: 'auto' }) + .run() + }, + icon: RiCodeBoxLine, + shortcut: '```' + }, + { + title: 'Blockquote', + command: ({ editor, range }) => { + editor + .chain() + .focus() + .deleteRange(range) + .setBlockquote() + .run() + }, + icon: RiDoubleQuotesL, + shortcut: '>' + }, +] + +export const suggestions = { + items: ({ query }: { query: string }) => { + query = query.toLowerCase().trim() + + if (!query) return SlashMenuItems + + const fuzzyResults = fuzzysort + .go(query, SlashMenuItems, { key: 'title' }) + .map((item) => ({ + ...item, + highlightedTitle: fuzzysort.highlight(item, "", "") + })) + + return fuzzyResults.map(({ obj, highlightedTitle }) => ({ ...obj, highlightedTitle })) + }, + + render: () => { + let component + let popup + let localProps + + return { + onStart: props => { + localProps = { ...props, event: '' } + + component = new ReactRenderer(CommandList, { + props: localProps, + editor: localProps.editor, + }) + + popup = tippy('body', { + getReferenceClientRect: localProps.clientRect, + appendTo: () => document.body, + content: component.element, + showOnCreate: true, + interactive: true, + trigger: 'manual', + placement: 'bottom-start', + }) + }, + + onUpdate(props) { + localProps = { ...props, event: '' } + + component.updateProps(localProps) + + popup[0].setProps({ getReferenceClientRect: localProps.clientRect }) + }, + + onKeyDown(props) { + component.updateProps({ ...localProps, event: props.event }) + + if (props.event.key === 'Escape') { + popup[0].hide() + + return true + } + + if (props.event.key === 'Enter') { + stopPrevent(props.event); + + return true + } + }, + + onExit() { + if (popup && popup[0]) popup[0]?.destroy() + if (component) component.destroy() + }, + } + }, +} diff --git a/src/pages/Newtab/index.css b/src/pages/Newtab/index.css index 646c8c5..b3ede48 100644 --- a/src/pages/Newtab/index.css +++ b/src/pages/Newtab/index.css @@ -14,6 +14,14 @@ flex-direction: column; } +.align-center { + align-items: center; +} + +.gap-8px { + gap: 8px; +} + .justify-center { justify-content: center; }