feat(app): vertical link rendering

This commit is contained in:
dextmorgn
2026-03-16 11:14:42 +01:00
parent 4f5c24295c
commit 976bd0ae99
10 changed files with 912 additions and 4 deletions

View File

@@ -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>
)
}

View File

@@ -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;
}
},
};
},
}),
];
},
});

View File

@@ -0,0 +1 @@
export { DragHandle } from './drag-handle'

View File

@@ -0,0 +1 @@
export { PasteMarkdown } from './markdown-paste'

View File

@@ -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
}
}
})
]
}
})

View File

@@ -0,0 +1,2 @@
export { SlashCommand } from './slash-command'
export type { SlashCommandItem } from './slash-command'

View File

@@ -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

View File

@@ -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

View File

@@ -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()

View File

@@ -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(