diff --git a/common/util/parse.ts b/common/util/parse.ts index 0bbd5cd9..72ceaf15 100644 --- a/common/util/parse.ts +++ b/common/util/parse.ts @@ -25,6 +25,7 @@ import Iframe from './tiptap-iframe' import TiptapTweet from './tiptap-tweet-type' import { find } from 'linkifyjs' import { uniq } from 'lodash' +import { TiptapSpoiler } from './tiptap-spoiler' /** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ export function getUrl(text: string) { @@ -103,6 +104,7 @@ export const exhibitExts = [ Mention, Iframe, TiptapTweet, + TiptapSpoiler, ] export function richTextToString(text?: JSONContent) { diff --git a/common/util/tiptap-spoiler.ts b/common/util/tiptap-spoiler.ts new file mode 100644 index 00000000..5502da58 --- /dev/null +++ b/common/util/tiptap-spoiler.ts @@ -0,0 +1,116 @@ +// adapted from @n8body/tiptap-spoiler + +import { + Mark, + markInputRule, + markPasteRule, + mergeAttributes, +} from '@tiptap/core' +import type { ElementType } from 'react' + +declare module '@tiptap/core' { + interface Commands { + spoilerEditor: { + setSpoiler: () => ReturnType + toggleSpoiler: () => ReturnType + unsetSpoiler: () => ReturnType + } + } +} + +export type SpoilerOptions = { + HTMLAttributes: Record + spoilerOpenClass: string + spoilerCloseClass?: string + inputRegex: RegExp + pasteRegex: RegExp + as: ElementType +} + +const spoilerInputRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))$/ +const spoilerPasteRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))/g + +export const TiptapSpoiler = Mark.create({ + name: 'spoiler', + + inline: true, + group: 'inline', + inclusive: false, + exitable: true, + content: 'inline*', + + priority: 200, // higher priority than other formatting so they go inside + + addOptions() { + return { + HTMLAttributes: { 'aria-label': 'spoiler' }, + spoilerOpenClass: '', + spoilerCloseClass: undefined, + inputRegex: spoilerInputRegex, + pasteRegex: spoilerPasteRegex, + as: 'span', + editing: false, + } + }, + + addCommands() { + return { + setSpoiler: + () => + ({ commands }) => + commands.setMark(this.name), + toggleSpoiler: + () => + ({ commands }) => + commands.toggleMark(this.name), + unsetSpoiler: + () => + ({ commands }) => + commands.unsetMark(this.name), + } + }, + + addInputRules() { + return [ + markInputRule({ + find: this.options.inputRegex, + type: this.type, + }), + ] + }, + + addPasteRules() { + return [ + markPasteRule({ + find: this.options.pasteRegex, + type: this.type, + }), + ] + }, + + parseHTML() { + return [ + { + tag: 'span', + getAttrs: (node) => + (node as HTMLElement).ariaLabel?.toLowerCase() === 'spoiler' && null, + }, + ] + }, + + renderHTML({ HTMLAttributes }) { + const elem = document.createElement(this.options.as as string) + + Object.entries( + mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, { + class: this.options.spoilerCloseClass ?? this.options.spoilerOpenClass, + }) + ).forEach(([attr, val]) => elem.setAttribute(attr, val)) + + elem.addEventListener('click', () => { + elem.setAttribute('class', this.options.spoilerOpenClass) + }) + + return elem + }, +}) diff --git a/web/components/editor.tsx b/web/components/editor.tsx index 95f18b3f..5050a261 100644 --- a/web/components/editor.tsx +++ b/web/components/editor.tsx @@ -29,6 +29,7 @@ import { EmbedModal } from './editor/embed-modal' import { CheckIcon, CodeIcon, + EyeOffIcon, PhotographIcon, PresentationChartLineIcon, TrashIcon, @@ -40,6 +41,7 @@ import BoldIcon from 'web/lib/icons/bold-icon' import ItalicIcon from 'web/lib/icons/italic-icon' import LinkIcon from 'web/lib/icons/link-icon' import { getUrl } from 'common/util/parse' +import { TiptapSpoiler } from 'common/util/tiptap-spoiler' const DisplayImage = Image.configure({ HTMLAttributes: { @@ -107,6 +109,9 @@ export function useTextEditor(props: { }), Iframe, TiptapTweet, + TiptapSpoiler.configure({ + spoilerOpenClass: 'rounded-sm bg-greyscale-2', + }), ], content: defaultValue, }) @@ -166,6 +171,7 @@ function FloatingMenu(props: { editor: Editor | null }) { const isBold = editor.isActive('bold') const isItalic = editor.isActive('italic') const isLink = editor.isActive('link') + const isSpoiler = editor.isActive('spoiler') const setLink = () => { const href = url && getUrl(url) @@ -194,6 +200,11 @@ function FloatingMenu(props: { editor: Editor | null }) { + ) : ( <> @@ -329,6 +340,11 @@ export function RichContent(props: { }), Iframe, TiptapTweet, + TiptapSpoiler.configure({ + spoilerOpenClass: 'rounded-sm bg-greyscale-2 cursor-text', + spoilerCloseClass: + 'rounded-sm bg-greyscale-6 text-greyscale-6 cursor-pointer select-none', + }), ], content, editable: false,