mirror of
https://github.com/linsa-io/linsa.git
synced 2026-04-22 16:28:36 +02:00
Setup (#112)
* wip * wip * wip3 * chore: utils * feat: add command * wip * fix: key duplicate * fix: move and check * fix: use react-use instead * fix: sidebar * chore: make dynamic * chore: tablet mode * chore: fix padding * chore: link instead of inbox * fix: use dnd kit * feat: add select component * chore: use atom * refactor: remove dnd provider * feat: disabled drag when sort is not manual * search route * . * feat: accessibility * fix: search * . * . * . * fix: sidebar collapsed * ai search layout * . * . * . * . * ai responsible content * . * . * . * . * . * global topic route * global topic correct route * topic buttons * sidebar search navigation * ai * Update jazz * . * . * . * . * . * learning status * . * . * chore: content header * fix: pointer none when dragging. prevent auto click after drag end * fix: confirm * fix: prevent drag when editing * chore: remove unused fn * fix: check propagation * chore: list * chore: tweak sonner * chore: update stuff * feat: add badge * chore: close edit when create * chore: escape on manage form * refactor: remove learn path * css: responsive item * chore: separate pages and topic * reafactor: remove new-schema * feat(types): extend jazz type so it can be nullable * chore: use new types * fix: missing deps * fix: link * fix: sidebar in layout * fix: quotes * css: use medium instead semi * Actual streaming and rendering markdown response * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * . * chore: metadata * feat: la-editor * . * fix: editor and page * . * . * . * . * . * . * fix: remove link * chore: page sidebar * fix: remove 'replace with learning status' * fix: link * fix: link * chore: update schema * chore: use new schema * fix: instead of showing error, just do unique slug * feat: create slug * refactor apply * update package json * fix: schema personal page * chore: editor * feat: pages * fix: metadata * fix: jazz provider * feat: handling data * feat: page detail * chore: server page to id * chore: use id instead of slug * chore: update content header * chore: update link header implementation * refactor: global.css * fix: la editor use animation frame * fix: editor export ref * refactor: page detail * chore: tidy up schema * chore: adapt to new schema * fix: wrap using settimeout * fix: wrap using settimeout * . * . --------- Co-authored-by: marshennikovaolga <marshennikova@gmail.com> Co-authored-by: Nikita <github@nikiv.dev> Co-authored-by: Anselm <anselm.eickhoff@gmail.com> Co-authored-by: Damian Tarnawski <gthetarnav@gmail.com>
This commit is contained in:
122
web/components/la-editor/extensions/slash-command/groups.ts
Normal file
122
web/components/la-editor/extensions/slash-command/groups.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Group } from "./types"
|
||||
|
||||
export const GROUPS: Group[] = [
|
||||
{
|
||||
name: "format",
|
||||
title: "Format",
|
||||
commands: [
|
||||
{
|
||||
name: "heading1",
|
||||
label: "Heading 1",
|
||||
iconName: "Heading1",
|
||||
description: "High priority section title",
|
||||
aliases: ["h1"],
|
||||
shortcuts: ["mod", "alt", "1"],
|
||||
action: editor => {
|
||||
editor.chain().focus().setHeading({ level: 1 }).run()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "heading2",
|
||||
label: "Heading 2",
|
||||
iconName: "Heading2",
|
||||
description: "Medium priority section title",
|
||||
aliases: ["h2"],
|
||||
shortcuts: ["mod", "alt", "2"],
|
||||
action: editor => {
|
||||
editor.chain().focus().setHeading({ level: 2 }).run()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "heading3",
|
||||
label: "Heading 3",
|
||||
iconName: "Heading3",
|
||||
description: "Low priority section title",
|
||||
aliases: ["h3"],
|
||||
shortcuts: ["mod", "alt", "3"],
|
||||
action: editor => {
|
||||
editor.chain().focus().setHeading({ level: 3 }).run()
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "list",
|
||||
title: "List",
|
||||
commands: [
|
||||
{
|
||||
name: "bulletList",
|
||||
label: "Bullet List",
|
||||
iconName: "List",
|
||||
description: "Unordered list of items",
|
||||
aliases: ["ul"],
|
||||
shortcuts: ["mod", "shift", "8"],
|
||||
action: editor => {
|
||||
editor.chain().focus().toggleBulletList().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "numberedList",
|
||||
label: "Numbered List",
|
||||
iconName: "ListOrdered",
|
||||
description: "Ordered list of items",
|
||||
aliases: ["ol"],
|
||||
shortcuts: ["mod", "shift", "7"],
|
||||
action: editor => {
|
||||
editor.chain().focus().toggleOrderedList().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "taskList",
|
||||
label: "Task List",
|
||||
iconName: "ListTodo",
|
||||
description: "Task list with todo items",
|
||||
aliases: ["todo"],
|
||||
shortcuts: ["mod", "shift", "8"],
|
||||
action: editor => {
|
||||
editor.chain().focus().toggleTaskList().run()
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "insert",
|
||||
title: "Insert",
|
||||
commands: [
|
||||
{
|
||||
name: "codeBlock",
|
||||
label: "Code Block",
|
||||
iconName: "SquareCode",
|
||||
description: "Code block with syntax highlighting",
|
||||
shortcuts: ["mod", "alt", "c"],
|
||||
shouldBeHidden: editor => editor.isActive("columns"),
|
||||
action: editor => {
|
||||
editor.chain().focus().setCodeBlock().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "horizontalRule",
|
||||
label: "Divider",
|
||||
iconName: "Divide",
|
||||
description: "Insert a horizontal divider",
|
||||
aliases: ["hr"],
|
||||
shortcuts: ["mod", "shift", "-"],
|
||||
action: editor => {
|
||||
editor.chain().focus().setHorizontalRule().run()
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "blockquote",
|
||||
label: "Blockquote",
|
||||
iconName: "Quote",
|
||||
description: "Element for quoting",
|
||||
shortcuts: ["mod", "shift", "b"],
|
||||
action: editor => {
|
||||
editor.chain().focus().setBlockquote().run()
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export default GROUPS
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./slash-command"
|
||||
155
web/components/la-editor/extensions/slash-command/menu-list.tsx
Normal file
155
web/components/la-editor/extensions/slash-command/menu-list.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
import { Command, MenuListProps } from "./types"
|
||||
import { getShortcutKeys } from "../../lib/utils"
|
||||
import { Icon } from "../../components/ui/icon"
|
||||
import { PopoverWrapper } from "../../components/ui/popover-wrapper"
|
||||
import { Shortcut } from "../../components/ui/shortcut"
|
||||
|
||||
export const MenuList = React.forwardRef((props: MenuListProps, ref) => {
|
||||
const scrollContainer = React.useRef<HTMLDivElement>(null)
|
||||
const activeItem = React.useRef<HTMLButtonElement>(null)
|
||||
const [selectedGroupIndex, setSelectedGroupIndex] = React.useState(0)
|
||||
const [selectedCommandIndex, setSelectedCommandIndex] = React.useState(0)
|
||||
|
||||
// Anytime the groups change, i.e. the user types to narrow it down, we want to
|
||||
// reset the current selection to the first menu item
|
||||
React.useEffect(() => {
|
||||
setSelectedGroupIndex(0)
|
||||
setSelectedCommandIndex(0)
|
||||
}, [props.items])
|
||||
|
||||
const selectItem = React.useCallback(
|
||||
(groupIndex: number, commandIndex: number) => {
|
||||
const command = props.items[groupIndex].commands[commandIndex]
|
||||
props.command(command)
|
||||
},
|
||||
[props]
|
||||
)
|
||||
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: { event: React.KeyboardEvent }) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
if (!props.items.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
const commands = props.items[selectedGroupIndex].commands
|
||||
|
||||
let newCommandIndex = selectedCommandIndex + 1
|
||||
let newGroupIndex = selectedGroupIndex
|
||||
|
||||
if (commands.length - 1 < newCommandIndex) {
|
||||
newCommandIndex = 0
|
||||
newGroupIndex = selectedGroupIndex + 1
|
||||
}
|
||||
|
||||
if (props.items.length - 1 < newGroupIndex) {
|
||||
newGroupIndex = 0
|
||||
}
|
||||
|
||||
setSelectedCommandIndex(newCommandIndex)
|
||||
setSelectedGroupIndex(newGroupIndex)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
if (!props.items.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
let newCommandIndex = selectedCommandIndex - 1
|
||||
let newGroupIndex = selectedGroupIndex
|
||||
|
||||
if (newCommandIndex < 0) {
|
||||
newGroupIndex = selectedGroupIndex - 1
|
||||
newCommandIndex = props.items[newGroupIndex]?.commands.length - 1 || 0
|
||||
}
|
||||
|
||||
if (newGroupIndex < 0) {
|
||||
newGroupIndex = props.items.length - 1
|
||||
newCommandIndex = props.items[newGroupIndex].commands.length - 1
|
||||
}
|
||||
|
||||
setSelectedCommandIndex(newCommandIndex)
|
||||
setSelectedGroupIndex(newGroupIndex)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
if (!props.items.length || selectedGroupIndex === -1 || selectedCommandIndex === -1) {
|
||||
return false
|
||||
}
|
||||
|
||||
selectItem(selectedGroupIndex, selectedCommandIndex)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}))
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeItem.current && scrollContainer.current) {
|
||||
const offsetTop = activeItem.current.offsetTop
|
||||
const offsetHeight = activeItem.current.offsetHeight
|
||||
|
||||
scrollContainer.current.scrollTop = offsetTop - offsetHeight
|
||||
}
|
||||
}, [selectedCommandIndex, selectedGroupIndex])
|
||||
|
||||
const createCommandClickHandler = React.useCallback(
|
||||
(groupIndex: number, commandIndex: number) => {
|
||||
return () => {
|
||||
selectItem(groupIndex, commandIndex)
|
||||
}
|
||||
},
|
||||
[selectItem]
|
||||
)
|
||||
|
||||
if (!props.items.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<PopoverWrapper ref={scrollContainer} className="flex max-h-[min(80vh,24rem)] flex-col overflow-auto p-1">
|
||||
{props.items.map((group, groupIndex: number) => (
|
||||
<React.Fragment key={group.title}>
|
||||
{group.commands.map((command: Command, commandIndex: number) => (
|
||||
<Button
|
||||
key={command.label}
|
||||
variant="ghost"
|
||||
onClick={createCommandClickHandler(groupIndex, commandIndex)}
|
||||
className={cn("relative w-full justify-between gap-2 px-3.5 py-1.5 font-normal", {
|
||||
"bg-accent text-accent-foreground":
|
||||
selectedGroupIndex === groupIndex && selectedCommandIndex === commandIndex
|
||||
})}
|
||||
>
|
||||
<Icon name={command.iconName} />
|
||||
<span className="truncate text-sm">{command.label}</span>
|
||||
<div className="flex flex-auto flex-row"></div>
|
||||
<Shortcut.Wrapper ariaLabel={getShortcutKeys(command.shortcuts)}>
|
||||
{command.shortcuts.map(shortcut => (
|
||||
<Shortcut.Key shortcut={shortcut} key={shortcut} />
|
||||
))}
|
||||
</Shortcut.Wrapper>
|
||||
</Button>
|
||||
))}
|
||||
{groupIndex !== props.items.length - 1 && <Separator className="my-1.5" />}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</PopoverWrapper>
|
||||
)
|
||||
})
|
||||
|
||||
MenuList.displayName = "MenuList"
|
||||
|
||||
export default MenuList
|
||||
@@ -0,0 +1,234 @@
|
||||
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)
|
||||
}
|
||||
|
||||
let 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
|
||||
26
web/components/la-editor/extensions/slash-command/types.ts
Normal file
26
web/components/la-editor/extensions/slash-command/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Editor } from "@tiptap/core"
|
||||
|
||||
import { icons } from "lucide-react"
|
||||
|
||||
export interface Group {
|
||||
name: string
|
||||
title: string
|
||||
commands: Command[]
|
||||
}
|
||||
|
||||
export interface Command {
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
aliases?: string[]
|
||||
shortcuts: string[]
|
||||
iconName: keyof typeof icons
|
||||
action: (editor: Editor) => void
|
||||
shouldBeHidden?: (editor: Editor) => boolean
|
||||
}
|
||||
|
||||
export interface MenuListProps {
|
||||
editor: Editor
|
||||
items: Group[]
|
||||
command: (command: Command) => void
|
||||
}
|
||||
Reference in New Issue
Block a user