Add ||spoilers|| (#942)
* Add ||spoilers|| * Add spoiler button to format menu
This commit is contained in:
parent
8929b2e6ba
commit
b7df1a7043
|
@ -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) {
|
||||
|
|
116
common/util/tiptap-spoiler.ts
Normal file
116
common/util/tiptap-spoiler.ts
Normal 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
|
||||
},
|
||||
})
|
|
@ -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 }) {
|
|||
<button onClick={() => (isLink ? unsetLink() : setUrl(''))}>
|
||||
<LinkIcon className={clsx('h-5', isLink && 'text-indigo-200')} />
|
||||
</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,
|
||||
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,
|
||||
|
|
Loading…
Reference in New Issue
Block a user