mirror of
https://github.com/linsa-io/linsa.git
synced 2026-02-22 02:17:59 +01:00
* wip * wip page * chore: style * wip pages * wip pages * chore: toggle * chore: link * feat: topic search * chore: page section * refactor: apply tailwind class ordering * fix: handle loggedIn user for guest route * feat: folder & image schema * chore: move utils to shared * refactor: tailwind class ordering * feat: img ext for editor * refactor: remove qa * fix: tanstack start * fix: wrong import * chore: use toast * chore: schema
263 lines
7.4 KiB
TypeScript
263 lines
7.4 KiB
TypeScript
import { Editor, Extension } from "@tiptap/core"
|
|
import { ReactRenderer } from "@tiptap/react"
|
|
import Suggestion, {
|
|
SuggestionProps,
|
|
SuggestionKeyDownProps,
|
|
} from "@tiptap/suggestion"
|
|
import { PluginKey } from "@tiptap/pm/state"
|
|
import tippy from "tippy.js"
|
|
|
|
import { GROUPS } from "./groups"
|
|
import { MenuList } from "./menu-list"
|
|
|
|
const EXTENSION_NAME = "slashCommand"
|
|
|
|
let popup: any
|
|
|
|
export const SlashCommand = Extension.create({
|
|
name: EXTENSION_NAME,
|
|
priority: 200,
|
|
|
|
onCreate() {
|
|
popup = tippy("body", {
|
|
interactive: true,
|
|
trigger: "manual",
|
|
placement: "bottom-start",
|
|
theme: "slash-command",
|
|
maxWidth: "16rem",
|
|
offset: [16, 8],
|
|
popperOptions: {
|
|
strategy: "fixed",
|
|
modifiers: [{ name: "flip", enabled: false }],
|
|
},
|
|
})
|
|
},
|
|
|
|
addProseMirrorPlugins() {
|
|
return [
|
|
Suggestion({
|
|
editor: this.editor,
|
|
char: "/",
|
|
allowSpaces: true,
|
|
startOfLine: true,
|
|
pluginKey: new PluginKey(EXTENSION_NAME),
|
|
|
|
allow: ({ state, range }) => {
|
|
const $from = state.doc.resolve(range.from)
|
|
const isRootDepth = $from.depth === 1
|
|
const isParagraph = $from.parent.type.name === "paragraph"
|
|
const isStartOfNode = $from.parent.textContent?.charAt(0) === "/"
|
|
const isInColumn = this.editor.isActive("column")
|
|
|
|
const afterContent = $from.parent.textContent?.substring(
|
|
$from.parent.textContent?.indexOf("/"),
|
|
)
|
|
const isValidAfterContent = !afterContent?.endsWith(" ")
|
|
|
|
return (
|
|
((isRootDepth && isParagraph && isStartOfNode) ||
|
|
(isInColumn && isParagraph && isStartOfNode)) &&
|
|
isValidAfterContent
|
|
)
|
|
},
|
|
|
|
command: ({ editor, props }: { editor: Editor; props: any }) => {
|
|
const { view, state } = editor
|
|
const { $head, $from } = view.state.selection
|
|
|
|
const end = $from.pos
|
|
const from = $head?.nodeBefore
|
|
? end -
|
|
($head.nodeBefore.text?.substring(
|
|
$head.nodeBefore.text?.indexOf("/"),
|
|
).length ?? 0)
|
|
: $from.start()
|
|
|
|
const tr = state.tr.deleteRange(from, end)
|
|
view.dispatch(tr)
|
|
|
|
props.action(editor)
|
|
view.focus()
|
|
},
|
|
|
|
items: ({ query }: { query: string }) => {
|
|
return GROUPS.map((group) => ({
|
|
...group,
|
|
commands: group.commands
|
|
.filter((item) => {
|
|
const labelNormalized = item.label.toLowerCase().trim()
|
|
const queryNormalized = query.toLowerCase().trim()
|
|
|
|
if (item.aliases) {
|
|
const aliases = item.aliases.map((alias) =>
|
|
alias.toLowerCase().trim(),
|
|
)
|
|
return (
|
|
labelNormalized.includes(queryNormalized) ||
|
|
aliases.includes(queryNormalized)
|
|
)
|
|
}
|
|
|
|
return labelNormalized.includes(queryNormalized)
|
|
})
|
|
.filter((command) =>
|
|
command.shouldBeHidden
|
|
? !command.shouldBeHidden(this.editor)
|
|
: true,
|
|
)
|
|
.map((command) => ({
|
|
...command,
|
|
isEnabled: true,
|
|
})),
|
|
})).filter((group) => group.commands.length > 0)
|
|
},
|
|
|
|
render: () => {
|
|
let component: any
|
|
let scrollHandler: (() => void) | null = null
|
|
|
|
return {
|
|
onStart: (props: SuggestionProps) => {
|
|
component = new ReactRenderer(MenuList, {
|
|
props,
|
|
editor: props.editor,
|
|
})
|
|
|
|
const { view } = props.editor
|
|
// const editorNode = view.dom as HTMLElement
|
|
|
|
const getReferenceClientRect = () => {
|
|
if (!props.clientRect) {
|
|
return props.editor.storage[EXTENSION_NAME].rect
|
|
}
|
|
|
|
const rect = props.clientRect()
|
|
|
|
if (!rect) {
|
|
return props.editor.storage[EXTENSION_NAME].rect
|
|
}
|
|
|
|
let yPos = rect.y
|
|
|
|
if (
|
|
rect.top + component.element.offsetHeight + 40 >
|
|
window.innerHeight
|
|
) {
|
|
const diff =
|
|
rect.top +
|
|
component.element.offsetHeight -
|
|
window.innerHeight +
|
|
40
|
|
yPos = rect.y - diff
|
|
}
|
|
|
|
// const editorXOffset = editorNode.getBoundingClientRect().x
|
|
return new DOMRect(rect.x, yPos, rect.width, rect.height)
|
|
}
|
|
|
|
scrollHandler = () => {
|
|
popup?.[0].setProps({
|
|
getReferenceClientRect,
|
|
})
|
|
}
|
|
|
|
view.dom.parentElement?.addEventListener("scroll", scrollHandler)
|
|
|
|
popup?.[0].setProps({
|
|
getReferenceClientRect,
|
|
appendTo: () => document.body,
|
|
content: component.element,
|
|
})
|
|
|
|
popup?.[0].show()
|
|
},
|
|
|
|
onUpdate(props: SuggestionProps) {
|
|
component.updateProps(props)
|
|
|
|
const { view } = props.editor
|
|
// const editorNode = view.dom as HTMLElement
|
|
|
|
const getReferenceClientRect = () => {
|
|
if (!props.clientRect) {
|
|
return props.editor.storage[EXTENSION_NAME].rect
|
|
}
|
|
|
|
const rect = props.clientRect()
|
|
|
|
if (!rect) {
|
|
return props.editor.storage[EXTENSION_NAME].rect
|
|
}
|
|
|
|
return new DOMRect(rect.x, rect.y, rect.width, rect.height)
|
|
}
|
|
|
|
const scrollHandler = () => {
|
|
popup?.[0].setProps({
|
|
getReferenceClientRect,
|
|
})
|
|
}
|
|
|
|
view.dom.parentElement?.addEventListener("scroll", scrollHandler)
|
|
|
|
props.editor.storage[EXTENSION_NAME].rect = props.clientRect
|
|
? getReferenceClientRect()
|
|
: {
|
|
width: 0,
|
|
height: 0,
|
|
left: 0,
|
|
top: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
}
|
|
popup?.[0].setProps({
|
|
getReferenceClientRect,
|
|
})
|
|
},
|
|
|
|
onKeyDown(props: SuggestionKeyDownProps) {
|
|
if (props.event.key === "Escape") {
|
|
popup?.[0].hide()
|
|
return true
|
|
}
|
|
|
|
if (!popup?.[0].state.isShown) {
|
|
popup?.[0].show()
|
|
}
|
|
|
|
return component.ref?.onKeyDown(props)
|
|
},
|
|
|
|
onExit(props) {
|
|
popup?.[0].hide()
|
|
if (scrollHandler) {
|
|
const { view } = props.editor
|
|
view.dom.parentElement?.removeEventListener(
|
|
"scroll",
|
|
scrollHandler,
|
|
)
|
|
}
|
|
component.destroy()
|
|
},
|
|
}
|
|
},
|
|
}),
|
|
]
|
|
},
|
|
|
|
addStorage() {
|
|
return {
|
|
rect: {
|
|
width: 0,
|
|
height: 0,
|
|
left: 0,
|
|
top: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
},
|
|
}
|
|
},
|
|
})
|
|
|
|
export default SlashCommand
|