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 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) {
|
||||||
|
|
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 {
|
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,
|
||||||
|
|
Loading…
Reference in New Issue
Block a user