mirror of
https://github.com/reconurge/flowsint.git
synced 2026-03-26 04:42:55 -05:00
feat(app): vertical link rendering
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
import * as React from 'react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { BubbleMenu } from '@tiptap/react/menus'
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Underline,
|
||||
Strikethrough,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
Pilcrow,
|
||||
Link as LinkIcon,
|
||||
Highlighter,
|
||||
ChevronDown
|
||||
} from 'lucide-react'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { LinkEditBlock } from '../link/link-edit-block'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TextBubbleMenuProps {
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
const prevent = (e: React.MouseEvent) => e.preventDefault()
|
||||
|
||||
const BubbleButton = ({
|
||||
active,
|
||||
onClick,
|
||||
disabled,
|
||||
children,
|
||||
}: {
|
||||
active?: boolean
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
children: React.ReactNode
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-sm text-sm transition-colors',
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
active && 'bg-accent text-accent-foreground',
|
||||
disabled && 'pointer-events-none opacity-50'
|
||||
)}
|
||||
onMouseDown={prevent}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
|
||||
export const TextBubbleMenu: React.FC<TextBubbleMenuProps> = ({ editor }) => {
|
||||
const [showLinkEditor, setShowLinkEditor] = React.useState(false)
|
||||
|
||||
const shouldShow = React.useCallback(
|
||||
({ editor: ed, from, to }: { editor: Editor; from: number; to: number }) => {
|
||||
if (from === to) return false
|
||||
if (!ed.isEditable) return false
|
||||
if (ed.isActive('codeBlock')) return false
|
||||
if (ed.isActive('link')) return false
|
||||
return true
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleSetLink = React.useCallback(
|
||||
(url: string, text?: string, openInNewTab?: boolean) => {
|
||||
if (text) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.insertContent({
|
||||
type: 'text',
|
||||
text,
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: { href: url, target: openInNewTab ? '_blank' : '' }
|
||||
}
|
||||
]
|
||||
})
|
||||
.run()
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setLink({ href: url, target: openInNewTab ? '_blank' : '' })
|
||||
.run()
|
||||
}
|
||||
setShowLinkEditor(false)
|
||||
},
|
||||
[editor]
|
||||
)
|
||||
|
||||
const activeHeading = editor.isActive('heading', { level: 1 })
|
||||
? 'H1'
|
||||
: editor.isActive('heading', { level: 2 })
|
||||
? 'H2'
|
||||
: editor.isActive('heading', { level: 3 })
|
||||
? 'H3'
|
||||
: 'P'
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
pluginKey="textBubbleMenu"
|
||||
shouldShow={shouldShow}
|
||||
options={{
|
||||
placement: 'top',
|
||||
offset: 8
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-0.5 rounded-lg border bg-popover p-1 shadow-md"
|
||||
onMouseDown={prevent}
|
||||
>
|
||||
<BubbleButton
|
||||
active={editor.isActive('bold')}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
disabled={!editor.can().chain().focus().toggleBold().run()}
|
||||
>
|
||||
<Bold className="size-4" />
|
||||
</BubbleButton>
|
||||
<BubbleButton
|
||||
active={editor.isActive('italic')}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
disabled={!editor.can().chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<Italic className="size-4" />
|
||||
</BubbleButton>
|
||||
<BubbleButton
|
||||
active={editor.isActive('underline')}
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
disabled={!editor.can().chain().focus().toggleUnderline().run()}
|
||||
>
|
||||
<Underline className="size-4" />
|
||||
</BubbleButton>
|
||||
<BubbleButton
|
||||
active={editor.isActive('strike')}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
disabled={!editor.can().chain().focus().toggleStrike().run()}
|
||||
>
|
||||
<Strikethrough className="size-4" />
|
||||
</BubbleButton>
|
||||
<BubbleButton
|
||||
active={editor.isActive('code')}
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
disabled={!editor.can().chain().focus().toggleCode().run()}
|
||||
>
|
||||
<Code className="size-4" />
|
||||
</BubbleButton>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-6" />
|
||||
|
||||
{/* Heading dropdown */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex h-7 items-center gap-0.5 rounded-sm px-2 text-xs font-semibold hover:bg-accent hover:text-accent-foreground"
|
||||
onMouseDown={prevent}
|
||||
>
|
||||
{activeHeading}
|
||||
<ChevronDown className="size-3" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-1" align="start">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-sm px-2 py-1 text-sm hover:bg-accent"
|
||||
onMouseDown={prevent}
|
||||
onClick={() => editor.chain().focus().setParagraph().run()}
|
||||
>
|
||||
<Pilcrow className="size-4" />
|
||||
Paragraph
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-sm px-2 py-1 text-sm hover:bg-accent"
|
||||
onMouseDown={prevent}
|
||||
onClick={() => editor.chain().focus().setHeading({ level: 1 }).run()}
|
||||
>
|
||||
<Heading1 className="size-4" />
|
||||
Heading 1
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-sm px-2 py-1 text-sm hover:bg-accent"
|
||||
onMouseDown={prevent}
|
||||
onClick={() => editor.chain().focus().setHeading({ level: 2 }).run()}
|
||||
>
|
||||
<Heading2 className="size-4" />
|
||||
Heading 2
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-2 rounded-sm px-2 py-1 text-sm hover:bg-accent"
|
||||
onMouseDown={prevent}
|
||||
onClick={() => editor.chain().focus().setHeading({ level: 3 }).run()}
|
||||
>
|
||||
<Heading3 className="size-4" />
|
||||
Heading 3
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-6" />
|
||||
|
||||
{/* Link */}
|
||||
<Popover open={showLinkEditor} onOpenChange={setShowLinkEditor}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex h-7 w-7 items-center justify-center rounded-sm hover:bg-accent hover:text-accent-foreground',
|
||||
editor.isActive('link') && 'bg-accent text-accent-foreground'
|
||||
)}
|
||||
onMouseDown={prevent}
|
||||
>
|
||||
<LinkIcon className="size-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full min-w-80 p-0" align="start">
|
||||
<LinkEditBlock onSave={handleSetLink} className="p-4" />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Separator orientation="vertical" className="mx-0.5 h-6" />
|
||||
|
||||
{/* Highlight */}
|
||||
<BubbleButton
|
||||
active={editor.isActive('highlight')}
|
||||
onClick={() => editor.chain().focus().toggleHighlight().run()}
|
||||
>
|
||||
<Highlighter className="size-4" />
|
||||
</BubbleButton>
|
||||
</div>
|
||||
</BubbleMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
|
||||
const dragHandleKey = new PluginKey("dragHandle");
|
||||
|
||||
function findBlockAt(
|
||||
view: any,
|
||||
y: number,
|
||||
): { pos: number; dom: HTMLElement } | null {
|
||||
const editorRect = view.dom.getBoundingClientRect();
|
||||
const paddingLeft = parseFloat(getComputedStyle(view.dom).paddingLeft) || 0;
|
||||
const coords = { left: editorRect.left + paddingLeft + 1, top: y };
|
||||
const posInfo = view.posAtCoords(coords);
|
||||
if (!posInfo) return null;
|
||||
|
||||
const $pos = view.state.doc.resolve(posInfo.pos);
|
||||
|
||||
const wrapperTypes = new Set([
|
||||
"bulletList", "orderedList", "taskList",
|
||||
]);
|
||||
|
||||
for (let d = $pos.depth; d >= 1; d--) {
|
||||
const node = $pos.node(d);
|
||||
if (!node.isBlock) continue;
|
||||
if (d === 1 && $pos.before(1) === 0) continue;
|
||||
if (wrapperTypes.has(node.type.name)) continue;
|
||||
if (node.isTextblock && d > 1) {
|
||||
const parent = $pos.node(d - 1);
|
||||
if (parent.type.name === "listItem" || parent.type.name === "taskItem") {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const pos = $pos.before(d);
|
||||
const dom = view.nodeDOM(pos);
|
||||
if (dom instanceof HTMLElement) {
|
||||
return { pos, dom };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const DragHandle = Extension.create({
|
||||
name: "dragHandle",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const handle = document.createElement("div");
|
||||
handle.className = "flowsint-drag-handle";
|
||||
handle.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor"><circle cx="4" cy="2" r="1.5"/><circle cx="10" cy="2" r="1.5"/><circle cx="4" cy="7" r="1.5"/><circle cx="10" cy="7" r="1.5"/><circle cx="4" cy="12" r="1.5"/><circle cx="10" cy="12" r="1.5"/></svg>`;
|
||||
|
||||
const indicator = document.createElement("div");
|
||||
indicator.className = "flowsint-drop-indicator";
|
||||
|
||||
let currentBlockPos: number | null = null;
|
||||
let dragging: { pos: number; ghost: HTMLElement } | null = null;
|
||||
let hideTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: dragHandleKey,
|
||||
view(view) {
|
||||
const wrapper = view.dom.parentElement!;
|
||||
wrapper.style.position = "relative";
|
||||
wrapper.appendChild(handle);
|
||||
wrapper.appendChild(indicator);
|
||||
|
||||
const positionHandle = (blockDom: HTMLElement, pos: number) => {
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
currentBlockPos = pos;
|
||||
|
||||
const wr = wrapper.getBoundingClientRect();
|
||||
const br = blockDom.getBoundingClientRect();
|
||||
|
||||
handle.style.top = `${br.top - wr.top}px`;
|
||||
handle.style.left = `${br.left - wr.left - 24}px`;
|
||||
handle.style.height = `${br.height}px`;
|
||||
handle.style.display = "flex";
|
||||
};
|
||||
|
||||
const hideAll = () => {
|
||||
handle.style.display = "none";
|
||||
indicator.style.display = "none";
|
||||
currentBlockPos = null;
|
||||
};
|
||||
|
||||
const scheduleHide = () => {
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
hideTimeout = setTimeout(hideAll, 150);
|
||||
};
|
||||
|
||||
const cancelHide = () => {
|
||||
if (hideTimeout) {
|
||||
clearTimeout(hideTimeout);
|
||||
hideTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const onEditorMouseMove = (e: MouseEvent) => {
|
||||
if (dragging || e.buttons > 0) return;
|
||||
const block = findBlockAt(view, e.clientY);
|
||||
if (block) {
|
||||
if (block.pos !== currentBlockPos) {
|
||||
positionHandle(block.dom, block.pos);
|
||||
} else {
|
||||
cancelHide();
|
||||
}
|
||||
} else {
|
||||
scheduleHide();
|
||||
}
|
||||
};
|
||||
|
||||
const onEditorLeave = () => {
|
||||
if (!dragging) scheduleHide();
|
||||
};
|
||||
const onHandleEnter = () => cancelHide();
|
||||
const onHandleLeave = () => {
|
||||
if (!dragging) scheduleHide();
|
||||
};
|
||||
|
||||
const onHandleMouseDown = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
if (currentBlockPos === null) return;
|
||||
|
||||
const pos = currentBlockPos;
|
||||
const blockDom = view.nodeDOM(pos) as HTMLElement;
|
||||
if (!blockDom) return;
|
||||
|
||||
const ghost = blockDom.cloneNode(true) as HTMLElement;
|
||||
ghost.className = "flowsint-drag-ghost";
|
||||
ghost.style.width = `${blockDom.offsetWidth}px`;
|
||||
ghost.style.left = `${e.clientX}px`;
|
||||
ghost.style.top = `${e.clientY}px`;
|
||||
document.body.appendChild(ghost);
|
||||
|
||||
blockDom.classList.add("flowsint-dragging-source");
|
||||
dragging = { pos, ghost };
|
||||
hideAll();
|
||||
|
||||
const onMouseMove = (ev: MouseEvent) => {
|
||||
if (!dragging) return;
|
||||
dragging.ghost.style.left = `${ev.clientX}px`;
|
||||
dragging.ghost.style.top = `${ev.clientY}px`;
|
||||
|
||||
const block = findBlockAt(view, ev.clientY);
|
||||
if (block) {
|
||||
const wr = wrapper.getBoundingClientRect();
|
||||
const br = block.dom.getBoundingClientRect();
|
||||
const above = ev.clientY < br.top + br.height / 2;
|
||||
indicator.style.top = `${(above ? br.top : br.bottom) - wr.top - 1}px`;
|
||||
indicator.style.left = `${br.left - wr.left}px`;
|
||||
indicator.style.width = `${br.width}px`;
|
||||
indicator.style.display = "block";
|
||||
} else {
|
||||
indicator.style.display = "none";
|
||||
}
|
||||
};
|
||||
|
||||
const onMouseUp = (ev: MouseEvent) => {
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
|
||||
if (!dragging) return;
|
||||
|
||||
dragging.ghost.remove();
|
||||
blockDom.classList.remove("flowsint-dragging-source");
|
||||
indicator.style.display = "none";
|
||||
|
||||
const originPos = dragging.pos;
|
||||
dragging = null;
|
||||
|
||||
const originNode = view.state.doc.nodeAt(originPos);
|
||||
if (!originNode) return;
|
||||
|
||||
const target = findBlockAt(view, ev.clientY);
|
||||
if (!target) return;
|
||||
|
||||
if (
|
||||
originPos < target.pos &&
|
||||
originPos + originNode.nodeSize > target.pos
|
||||
)
|
||||
return;
|
||||
|
||||
const targetNode = view.state.doc.nodeAt(target.pos);
|
||||
if (!targetNode) return;
|
||||
|
||||
const targetRect = target.dom.getBoundingClientRect();
|
||||
const above = ev.clientY < targetRect.top + targetRect.height / 2;
|
||||
const insertPos = above
|
||||
? target.pos
|
||||
: target.pos + targetNode.nodeSize;
|
||||
|
||||
if (
|
||||
insertPos === originPos ||
|
||||
insertPos === originPos + originNode.nodeSize
|
||||
)
|
||||
return;
|
||||
|
||||
const tr = view.state.tr;
|
||||
if (originPos < insertPos) {
|
||||
const adjusted = insertPos - originNode.nodeSize;
|
||||
tr.delete(originPos, originPos + originNode.nodeSize);
|
||||
tr.insert(adjusted, originNode);
|
||||
} else {
|
||||
tr.insert(insertPos, originNode);
|
||||
tr.delete(
|
||||
originPos + originNode.nodeSize,
|
||||
originPos + originNode.nodeSize * 2,
|
||||
);
|
||||
}
|
||||
view.dispatch(tr);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
};
|
||||
|
||||
view.dom.addEventListener("mousemove", onEditorMouseMove);
|
||||
view.dom.addEventListener("mouseleave", onEditorLeave);
|
||||
handle.addEventListener("mouseenter", onHandleEnter);
|
||||
handle.addEventListener("mouseleave", onHandleLeave);
|
||||
handle.addEventListener("mousedown", onHandleMouseDown);
|
||||
|
||||
return {
|
||||
destroy() {
|
||||
if (hideTimeout) clearTimeout(hideTimeout);
|
||||
view.dom.removeEventListener("mousemove", onEditorMouseMove);
|
||||
view.dom.removeEventListener("mouseleave", onEditorLeave);
|
||||
handle.removeEventListener("mouseenter", onHandleEnter);
|
||||
handle.removeEventListener("mouseleave", onHandleLeave);
|
||||
handle.removeEventListener("mousedown", onHandleMouseDown);
|
||||
handle.remove();
|
||||
indicator.remove();
|
||||
if (dragging) {
|
||||
dragging.ghost.remove();
|
||||
dragging = null;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { DragHandle } from './drag-handle'
|
||||
@@ -0,0 +1 @@
|
||||
export { PasteMarkdown } from './markdown-paste'
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin } from '@tiptap/pm/state'
|
||||
import { MarkdownManager } from '@tiptap/markdown'
|
||||
|
||||
function looksLikeMarkdown(text: string): boolean {
|
||||
return (
|
||||
/^#{1,6}\s/m.test(text) ||
|
||||
/\*\*[^*]+\*\*/.test(text) ||
|
||||
/\[.+\]\(.+\)/.test(text) ||
|
||||
/^[-*+]\s/m.test(text) ||
|
||||
/^\d+\.\s/m.test(text) ||
|
||||
/^>\s/m.test(text) ||
|
||||
/^```/m.test(text) ||
|
||||
/^---$/m.test(text) ||
|
||||
/!\[.*\]\(.*\)/.test(text) ||
|
||||
/^- \[[ x]\]/m.test(text)
|
||||
)
|
||||
}
|
||||
|
||||
export const PasteMarkdown = Extension.create({
|
||||
name: 'pasteMarkdown',
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
markdownManager: null as MarkdownManager | null
|
||||
}
|
||||
},
|
||||
|
||||
onCreate() {
|
||||
this.storage.markdownManager = new MarkdownManager({
|
||||
extensions: this.editor.extensionManager.baseExtensions
|
||||
})
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const { editor } = this
|
||||
const storage = this.storage
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handlePaste(_view, event) {
|
||||
const text = event.clipboardData?.getData('text/plain')
|
||||
if (!text) return false
|
||||
|
||||
if (storage.markdownManager && looksLikeMarkdown(text)) {
|
||||
try {
|
||||
const json = storage.markdownManager.parse(text)
|
||||
editor.chain().focus().insertContent(json).run()
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('[PasteMarkdown]', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SlashCommand } from './slash-command'
|
||||
export type { SlashCommandItem } from './slash-command'
|
||||
@@ -0,0 +1,131 @@
|
||||
import { forwardRef, useEffect, useImperativeHandle, useState, useCallback } from 'react'
|
||||
import type { SlashCommandItem } from './slash-command'
|
||||
import {
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListTodo,
|
||||
Quote,
|
||||
Code,
|
||||
Minus,
|
||||
ImageIcon
|
||||
} from 'lucide-react'
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
ListTodo,
|
||||
Quote,
|
||||
Code,
|
||||
Minus,
|
||||
ImageIcon
|
||||
}
|
||||
|
||||
export interface SlashCommandListProps {
|
||||
items: SlashCommandItem[]
|
||||
command: (item: SlashCommandItem) => void
|
||||
}
|
||||
|
||||
export interface SlashCommandListRef {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean
|
||||
}
|
||||
|
||||
const SlashCommandList = forwardRef<SlashCommandListRef, SlashCommandListProps>((props, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = props.items[index]
|
||||
if (item) {
|
||||
props.command(item)
|
||||
}
|
||||
},
|
||||
[props]
|
||||
)
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [props.items])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||
if (event.key === 'ArrowUp') {
|
||||
setSelectedIndex((prev) => (prev + props.items.length - 1) % props.items.length)
|
||||
return true
|
||||
}
|
||||
if (event.key === 'ArrowDown') {
|
||||
setSelectedIndex((prev) => (prev + 1) % props.items.length)
|
||||
return true
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
selectItem(selectedIndex)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}))
|
||||
|
||||
// Group items by category
|
||||
const grouped = props.items.reduce<Record<string, SlashCommandItem[]>>((acc, item) => {
|
||||
if (!acc[item.category]) acc[item.category] = []
|
||||
acc[item.category].push(item)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// Flat index tracking
|
||||
let flatIndex = -1
|
||||
|
||||
if (!props.items.length) {
|
||||
return (
|
||||
<div className="z-50 w-[280px] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md">
|
||||
<div className="px-2 py-1.5 text-sm text-muted-foreground">No results</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="z-50 w-[280px] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md">
|
||||
<div className="overflow-y-auto max-h-[300px] p-1">
|
||||
{Object.entries(grouped).map(([category, items]) => (
|
||||
<div key={category}>
|
||||
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">{category}</div>
|
||||
{items.map((item) => {
|
||||
flatIndex++
|
||||
const currentIndex = flatIndex
|
||||
const Icon = iconMap[item.icon]
|
||||
return (
|
||||
<button
|
||||
key={item.title}
|
||||
className={`relative flex w-full cursor-pointer select-none items-center gap-3 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground ${
|
||||
currentIndex === selectedIndex ? 'bg-accent text-accent-foreground' : ''
|
||||
}`}
|
||||
onClick={() => selectItem(currentIndex)}
|
||||
type="button"
|
||||
>
|
||||
{Icon && (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md border bg-background">
|
||||
<Icon size={16} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col text-left">
|
||||
<span className="font-medium">{item.title}</span>
|
||||
<span className="text-xs text-muted-foreground">{item.description}</span>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
SlashCommandList.displayName = 'SlashCommandList'
|
||||
|
||||
export { SlashCommandList }
|
||||
export default SlashCommandList
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { computePosition, flip, shift } from '@floating-ui/dom'
|
||||
import { posToDOMRect, ReactRenderer } from '@tiptap/react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import Suggestion from '@tiptap/suggestion'
|
||||
import type { SuggestionOptions, SuggestionProps } from '@tiptap/suggestion'
|
||||
import { SlashCommandList } from './slash-command-list'
|
||||
import type { SlashCommandListRef } from './slash-command-list'
|
||||
|
||||
export interface SlashCommandItem {
|
||||
title: string
|
||||
description: string
|
||||
category: string
|
||||
icon: string
|
||||
command: (editor: Editor) => void
|
||||
}
|
||||
|
||||
const updatePosition = (editor: Editor, element: HTMLElement) => {
|
||||
const virtualElement = {
|
||||
getBoundingClientRect: () =>
|
||||
posToDOMRect(editor.view, editor.state.selection.from, editor.state.selection.to)
|
||||
}
|
||||
|
||||
computePosition(virtualElement, element, {
|
||||
placement: 'bottom-start',
|
||||
strategy: 'absolute',
|
||||
middleware: [shift(), flip()]
|
||||
}).then(({ x, y, strategy }) => {
|
||||
element.style.width = 'max-content'
|
||||
element.style.position = strategy
|
||||
element.style.left = `${x}px`
|
||||
element.style.top = `${y}px`
|
||||
})
|
||||
}
|
||||
|
||||
const slashCommandItems: SlashCommandItem[] = [
|
||||
{
|
||||
title: 'Heading 1',
|
||||
description: 'Large section heading',
|
||||
category: 'Hierarchy',
|
||||
icon: 'Heading1',
|
||||
command: (editor) => editor.chain().focus().setHeading({ level: 1 }).run()
|
||||
},
|
||||
{
|
||||
title: 'Heading 2',
|
||||
description: 'Medium section heading',
|
||||
category: 'Hierarchy',
|
||||
icon: 'Heading2',
|
||||
command: (editor) => editor.chain().focus().setHeading({ level: 2 }).run()
|
||||
},
|
||||
{
|
||||
title: 'Heading 3',
|
||||
description: 'Small section heading',
|
||||
category: 'Hierarchy',
|
||||
icon: 'Heading3',
|
||||
command: (editor) => editor.chain().focus().setHeading({ level: 3 }).run()
|
||||
},
|
||||
{
|
||||
title: 'Bullet List',
|
||||
description: 'Create a simple bullet list',
|
||||
category: 'Lists',
|
||||
icon: 'List',
|
||||
command: (editor) => editor.chain().focus().toggleBulletList().run()
|
||||
},
|
||||
{
|
||||
title: 'Numbered List',
|
||||
description: 'Create a numbered list',
|
||||
category: 'Lists',
|
||||
icon: 'ListOrdered',
|
||||
command: (editor) => editor.chain().focus().toggleOrderedList().run()
|
||||
},
|
||||
{
|
||||
title: 'Task List',
|
||||
description: 'Create a task checklist',
|
||||
category: 'Lists',
|
||||
icon: 'ListTodo',
|
||||
command: (editor) => editor.chain().focus().toggleTaskList().run()
|
||||
},
|
||||
{
|
||||
title: 'Blockquote',
|
||||
description: 'Add a quote block',
|
||||
category: 'Blocks',
|
||||
icon: 'Quote',
|
||||
command: (editor) => editor.chain().focus().toggleBlockquote().run()
|
||||
},
|
||||
{
|
||||
title: 'Code Block',
|
||||
description: 'Add a code snippet',
|
||||
category: 'Blocks',
|
||||
icon: 'Code',
|
||||
command: (editor) => editor.chain().focus().toggleCodeBlock().run()
|
||||
},
|
||||
{
|
||||
title: 'Divider',
|
||||
description: 'Add a horizontal divider',
|
||||
category: 'Blocks',
|
||||
icon: 'Minus',
|
||||
command: (editor) => editor.chain().focus().setHorizontalRule().run()
|
||||
},
|
||||
{
|
||||
title: 'Image',
|
||||
description: 'Upload or embed an image',
|
||||
category: 'Media',
|
||||
icon: 'ImageIcon',
|
||||
command: (editor) => {
|
||||
const input = document.createElement('input')
|
||||
input.type = 'file'
|
||||
input.accept = 'image/*'
|
||||
input.onchange = () => {
|
||||
const file = input.files?.[0]
|
||||
if (file) {
|
||||
const blobUrl = URL.createObjectURL(file)
|
||||
editor.commands.insertContent({
|
||||
type: 'image',
|
||||
attrs: {
|
||||
src: blobUrl,
|
||||
alt: file.name,
|
||||
title: file.name
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
input.click()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export const SlashCommand = Extension.create({
|
||||
name: 'slashCommand',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: '/',
|
||||
startOfLine: false,
|
||||
items: ({ query }: { query: string }): SlashCommandItem[] => {
|
||||
return slashCommandItems.filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(query.toLowerCase()) ||
|
||||
item.category.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<SlashCommandListRef> | undefined
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionProps) => {
|
||||
component = new ReactRenderer(SlashCommandList, {
|
||||
props,
|
||||
editor: props.editor
|
||||
})
|
||||
if (!props.clientRect) return
|
||||
|
||||
const element = component.element as HTMLElement
|
||||
element.style.position = 'absolute'
|
||||
element.style.zIndex = '9999'
|
||||
document.body.appendChild(element)
|
||||
updatePosition(props.editor, element)
|
||||
},
|
||||
|
||||
onUpdate(props: SuggestionProps) {
|
||||
if (!component) return
|
||||
component.updateProps(props)
|
||||
if (!props.clientRect) return
|
||||
|
||||
const element = component.element as HTMLElement
|
||||
updatePosition(props.editor, element)
|
||||
},
|
||||
|
||||
onKeyDown(props: { event: KeyboardEvent }) {
|
||||
if (props.event.key === 'Escape') {
|
||||
component?.destroy()
|
||||
return true
|
||||
}
|
||||
return component?.ref?.onKeyDown(props) ?? false
|
||||
},
|
||||
|
||||
onExit() {
|
||||
if (!component) return
|
||||
component.element.remove()
|
||||
component.destroy()
|
||||
}
|
||||
}
|
||||
},
|
||||
command: ({
|
||||
editor,
|
||||
range,
|
||||
props
|
||||
}: {
|
||||
editor: Editor
|
||||
range: { from: number; to: number }
|
||||
props: SlashCommandItem
|
||||
}) => {
|
||||
editor.chain().focus().deleteRange(range).run()
|
||||
props.command(editor)
|
||||
}
|
||||
} as Partial<SuggestionOptions>
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion
|
||||
})
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
export default SlashCommand
|
||||
@@ -202,8 +202,12 @@ export const renderLink = ({
|
||||
textAngle = Math.atan2(sdy, sdx)
|
||||
}
|
||||
|
||||
if (textAngle > CONSTANTS.HALF_PI || textAngle < -CONSTANTS.HALF_PI) {
|
||||
textAngle += textAngle > 0 ? -CONSTANTS.PI : CONSTANTS.PI
|
||||
const linkLabelHorizontal = forceSettings?.linkLabelHorizontal?.value ?? false
|
||||
|
||||
if (!linkLabelHorizontal) {
|
||||
if (textAngle > CONSTANTS.HALF_PI || textAngle < -CONSTANTS.HALF_PI) {
|
||||
textAngle += textAngle > 0 ? -CONSTANTS.PI : CONSTANTS.PI
|
||||
}
|
||||
}
|
||||
|
||||
const linkLabelSetting = forceSettings?.linkLabelFontSize?.value ?? 50
|
||||
@@ -221,7 +225,9 @@ export const renderLink = ({
|
||||
|
||||
ctx.save()
|
||||
ctx.translate(tempPos.x, tempPos.y)
|
||||
ctx.rotate(textAngle)
|
||||
if (!linkLabelHorizontal) {
|
||||
ctx.rotate(textAngle)
|
||||
}
|
||||
|
||||
const borderRadius = linkFontSize * 0.1
|
||||
ctx.beginPath()
|
||||
|
||||
@@ -96,6 +96,12 @@ const DEFAULT_SETTINGS = {
|
||||
description:
|
||||
'Adjusts the font size of link labels (percentage of base size, scales with zoom)'
|
||||
},
|
||||
linkLabelHorizontal: {
|
||||
name: 'Horizontal Link Labels',
|
||||
type: 'boolean',
|
||||
value: false,
|
||||
description: 'Display link labels horizontally instead of following the edge angle.'
|
||||
},
|
||||
dagLevelDistance: {
|
||||
name: 'DAG Level Distance',
|
||||
type: 'number',
|
||||
@@ -401,7 +407,7 @@ type GraphGeneralSettingsStore = {
|
||||
}
|
||||
|
||||
// Storage version - increment this whenever you make breaking changes to DEFAULT_SETTINGS
|
||||
const STORAGE_VERSION = 7
|
||||
const STORAGE_VERSION = 8
|
||||
|
||||
export const useGraphSettingsStore = create<GraphGeneralSettingsStore>()(
|
||||
persist(
|
||||
|
||||
Reference in New Issue
Block a user