Add ||spoilers|| (#942)

* Add ||spoilers||
* Add spoiler button to format menu
This commit is contained in:
Sinclair Chen 2022-09-29 14:28:04 -07:00 committed by GitHub
parent 8929b2e6ba
commit b7df1a7043
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 134 additions and 0 deletions

View File

@ -25,6 +25,7 @@ import Iframe from './tiptap-iframe'
import TiptapTweet from './tiptap-tweet-type' import TiptapTweet from './tiptap-tweet-type'
import { find } from 'linkifyjs' import { find } from 'linkifyjs'
import { uniq } from 'lodash' import { uniq } from 'lodash'
import { TiptapSpoiler } from './tiptap-spoiler'
/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ /** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */
export function getUrl(text: string) { export function getUrl(text: string) {
@ -103,6 +104,7 @@ export const exhibitExts = [
Mention, Mention,
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler,
] ]
export function richTextToString(text?: JSONContent) { export function richTextToString(text?: JSONContent) {

View File

@ -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<ReturnType> {
spoilerEditor: {
setSpoiler: () => ReturnType
toggleSpoiler: () => ReturnType
unsetSpoiler: () => ReturnType
}
}
}
export type SpoilerOptions = {
HTMLAttributes: Record<string, any>
spoilerOpenClass: string
spoilerCloseClass?: string
inputRegex: RegExp
pasteRegex: RegExp
as: ElementType
}
const spoilerInputRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))$/
const spoilerPasteRegex = /(?:^|\s)((?:\|\|)((?:[^||]+))(?:\|\|))/g
export const TiptapSpoiler = Mark.create<SpoilerOptions>({
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
},
})

View File

@ -29,6 +29,7 @@ import { EmbedModal } from './editor/embed-modal'
import { import {
CheckIcon, CheckIcon,
CodeIcon, CodeIcon,
EyeOffIcon,
PhotographIcon, PhotographIcon,
PresentationChartLineIcon, PresentationChartLineIcon,
TrashIcon, TrashIcon,
@ -40,6 +41,7 @@ import BoldIcon from 'web/lib/icons/bold-icon'
import ItalicIcon from 'web/lib/icons/italic-icon' import ItalicIcon from 'web/lib/icons/italic-icon'
import LinkIcon from 'web/lib/icons/link-icon' import LinkIcon from 'web/lib/icons/link-icon'
import { getUrl } from 'common/util/parse' import { getUrl } from 'common/util/parse'
import { TiptapSpoiler } from 'common/util/tiptap-spoiler'
const DisplayImage = Image.configure({ const DisplayImage = Image.configure({
HTMLAttributes: { HTMLAttributes: {
@ -107,6 +109,9 @@ export function useTextEditor(props: {
}), }),
Iframe, Iframe,
TiptapTweet, TiptapTweet,
TiptapSpoiler.configure({
spoilerOpenClass: 'rounded-sm bg-greyscale-2',
}),
], ],
content: defaultValue, content: defaultValue,
}) })
@ -166,6 +171,7 @@ function FloatingMenu(props: { editor: Editor | null }) {
const isBold = editor.isActive('bold') const isBold = editor.isActive('bold')
const isItalic = editor.isActive('italic') const isItalic = editor.isActive('italic')
const isLink = editor.isActive('link') const isLink = editor.isActive('link')
const isSpoiler = editor.isActive('spoiler')
const setLink = () => { const setLink = () => {
const href = url && getUrl(url) const href = url && getUrl(url)
@ -194,6 +200,11 @@ function FloatingMenu(props: { editor: Editor | null }) {
<button onClick={() => (isLink ? unsetLink() : setUrl(''))}> <button onClick={() => (isLink ? unsetLink() : setUrl(''))}>
<LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} /> <LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} />
</button> </button>
<button onClick={() => editor.chain().focus().toggleSpoiler().run()}>
<EyeOffIcon
className={clsx('h-5', isSpoiler && 'text-indigo-200')}
/>
</button>
</> </>
) : ( ) : (
<> <>
@ -329,6 +340,11 @@ export function RichContent(props: {
}), }),
Iframe, Iframe,
TiptapTweet, 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, content,
editable: false, editable: false,