mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-17 05:59:47 +02:00
chore: Enhancement + New Feature (#185)
* 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
This commit is contained in:
262
web/shared/editor/extensions/slash-command/slash-command.ts
Normal file
262
web/shared/editor/extensions/slash-command/slash-command.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
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
|
||||
Reference in New Issue
Block a user