mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-26 23:49:51 +00:00
Core: fix Shiki languages not loaded under lazy mode
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-core': patch
|
||||
---
|
||||
|
||||
fix Shiki languages not loaded under lazy mode
|
||||
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -0,0 +1,267 @@
|
||||
/* DocumentEditor — writing surface, scoped under .fe-document-editor */
|
||||
|
||||
.fe-document-editor__canvas {
|
||||
background: var(--color-fe-input);
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror {
|
||||
padding: 1.35rem;
|
||||
outline: none;
|
||||
caret-color: var(--color-fe-primary);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Bind ::selection to .ProseMirror (no space before ::). Use fe-info vs fe-input so the band reads on dark surfaces (fe-accent is too close to the canvas). */
|
||||
.fe-document-editor .ProseMirror::selection,
|
||||
.fe-document-editor .ProseMirror::-moz-selection {
|
||||
background: color-mix(in oklch, var(--color-fe-info) 48%, var(--color-fe-input));
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror > * + * {
|
||||
margin-top: 0.62em;
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror h1 {
|
||||
font-size: clamp(1.45rem, 2.8vw, 1.85rem);
|
||||
font-weight: 650;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.18;
|
||||
color: var(--color-fe-foreground);
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror h2 {
|
||||
font-size: clamp(1.2rem, 2.2vw, 1.38rem);
|
||||
font-weight: 620;
|
||||
letter-spacing: -0.022em;
|
||||
line-height: 1.22;
|
||||
color: color-mix(in oklch, var(--color-fe-foreground) 96%, var(--color-fe-muted-foreground));
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror h3 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.014em;
|
||||
line-height: 1.28;
|
||||
color: color-mix(in oklch, var(--color-fe-foreground) 92%, var(--color-fe-muted-foreground));
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror p {
|
||||
line-height: 1.68;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror blockquote {
|
||||
position: relative;
|
||||
margin: 0.85rem 0;
|
||||
padding: 0.65rem 1rem 0.65rem 1.15rem;
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
border: 1px solid color-mix(in oklch, var(--color-fe-border) 70%, transparent);
|
||||
border-left-width: 3px;
|
||||
border-left-color: color-mix(in oklch, var(--color-fe-border) 45%, var(--color-fe-ring));
|
||||
background: color-mix(in oklch, var(--color-fe-muted) 35%, transparent);
|
||||
color: color-mix(in oklch, var(--color-fe-muted-foreground) 88%, var(--color-fe-foreground));
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror ul,
|
||||
.fe-document-editor .ProseMirror ol {
|
||||
padding-left: 1.45rem;
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror li {
|
||||
margin: 0.28em 0;
|
||||
padding-left: 0.2em;
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror li::marker {
|
||||
color: color-mix(in oklch, var(--color-fe-muted-foreground) 75%, var(--color-fe-ring));
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
margin: 1.65rem 0;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
var(--color-fe-border) 12%,
|
||||
var(--color-fe-border) 88%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.86em;
|
||||
font-weight: 450;
|
||||
padding: 0.14em 0.4em;
|
||||
border-radius: 0.35rem;
|
||||
background: color-mix(in oklch, var(--color-fe-muted) 72%, transparent);
|
||||
border: 1px solid color-mix(in oklch, var(--color-fe-border) 55%, transparent);
|
||||
color: color-mix(in oklch, var(--color-fe-foreground) 92%, var(--color-fe-info));
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror pre {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.58;
|
||||
padding: 1rem 1.05rem;
|
||||
border-radius: 0.65rem;
|
||||
background: color-mix(in oklch, var(--color-fe-background) 55%, var(--color-fe-muted));
|
||||
border: 1px solid color-mix(in oklch, var(--color-fe-border) 80%, transparent);
|
||||
box-shadow: inset 0 1px 0 0 color-mix(in oklch, var(--color-fe-foreground) 4%, transparent);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror pre code {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* lowlight / highlight.js tokens */
|
||||
.fe-document-editor .ProseMirror .hljs-comment,
|
||||
.fe-document-editor .ProseMirror .hljs-quote {
|
||||
color: var(--color-fe-muted-foreground);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror .hljs-keyword,
|
||||
.fe-document-editor .ProseMirror .hljs-selector-tag,
|
||||
.fe-document-editor .ProseMirror .hljs-title {
|
||||
color: color-mix(in oklch, var(--color-fe-info) 72%, var(--color-fe-foreground));
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror .hljs-string,
|
||||
.fe-document-editor .ProseMirror .hljs-attr,
|
||||
.fe-document-editor .ProseMirror .hljs-template-variable {
|
||||
color: color-mix(in oklch, var(--color-fe-success) 48%, var(--color-fe-foreground));
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror .hljs-number,
|
||||
.fe-document-editor .ProseMirror .hljs-literal {
|
||||
color: color-mix(in oklch, var(--color-fe-warning) 45%, var(--color-fe-foreground));
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror .hljs-function,
|
||||
.fe-document-editor .ProseMirror .hljs-class {
|
||||
color: color-mix(in oklch, var(--color-fe-ring) 65%, var(--color-fe-foreground));
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror .tableWrapper {
|
||||
margin: 0.85rem 0;
|
||||
overflow-x: auto;
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid color-mix(in oklch, var(--color-fe-border) 85%, transparent);
|
||||
background: color-mix(in oklch, var(--color-fe-card) 88%, var(--color-fe-muted));
|
||||
box-shadow: 0 1px 0 0 color-mix(in oklch, var(--color-fe-foreground) 3%, transparent);
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror th,
|
||||
.fe-document-editor .ProseMirror td {
|
||||
border: 1px solid color-mix(in oklch, var(--color-fe-border) 70%, transparent);
|
||||
padding: 0.5rem 0.7rem;
|
||||
vertical-align: top;
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror th {
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
background: color-mix(in oklch, var(--color-fe-muted) 48%, transparent);
|
||||
color: color-mix(in oklch, var(--color-fe-foreground) 90%, var(--color-fe-muted-foreground));
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror td p,
|
||||
.fe-document-editor .ProseMirror th p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror .selectedCell {
|
||||
background: color-mix(in oklch, var(--color-fe-accent) 38%, transparent);
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 0.55rem;
|
||||
border: 1px solid color-mix(in oklch, var(--color-fe-border) 75%, transparent);
|
||||
display: block;
|
||||
margin: 0.65rem 0;
|
||||
box-shadow: 0 10px 32px -16px color-mix(in oklch, var(--color-fe-background) 35%, black);
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror img.ProseMirror-selectednode {
|
||||
outline: 2px solid color-mix(in oklch, var(--color-fe-ring) 85%, transparent);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Placeholder (@tiptap/extension-placeholder decorations) */
|
||||
.fe-document-editor .ProseMirror .is-empty[data-placeholder]::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
color: var(--color-fe-muted-foreground);
|
||||
opacity: 0.5;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.fe-document-editor .ProseMirror.ProseMirror-focused .is-empty[data-placeholder]::before {
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.fe-document-drag-handle {
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.fe-document-drag-handle__grip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.35rem;
|
||||
height: 1.5rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0.35rem;
|
||||
background: transparent;
|
||||
color: color-mix(in oklch, var(--color-fe-muted-foreground) 88%, transparent);
|
||||
cursor: grab;
|
||||
opacity: 0.32;
|
||||
transition:
|
||||
opacity 0.14s ease,
|
||||
background 0.14s ease,
|
||||
color 0.14s ease;
|
||||
}
|
||||
|
||||
.fe-document-drag-handle__grip:hover,
|
||||
.fe-document-drag-handle__grip:focus-visible {
|
||||
opacity: 1;
|
||||
background: color-mix(in oklch, var(--color-fe-muted) 55%, transparent);
|
||||
color: var(--color-fe-muted-foreground);
|
||||
}
|
||||
|
||||
.fe-document-drag-handle__grip:active {
|
||||
cursor: grabbing;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.fe-slash-row {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
@import 'tailwindcss';
|
||||
@import './document-editor.css';
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import './globals.css';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create Next App',
|
||||
description: 'Generated by create next app',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,848 @@
|
||||
'use client';
|
||||
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
import Collaboration from '@tiptap/extension-collaboration';
|
||||
import DragHandle from '@tiptap/extension-drag-handle-react';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import { NodeRange } from '@tiptap/extension-node-range';
|
||||
import Placeholder from '@tiptap/extension-placeholder';
|
||||
import { Table } from '@tiptap/extension-table';
|
||||
import { TableCell } from '@tiptap/extension-table-cell';
|
||||
import { TableHeader } from '@tiptap/extension-table-header';
|
||||
import { TableRow } from '@tiptap/extension-table-row';
|
||||
import type { Editor, JSONContent } from '@tiptap/react';
|
||||
import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
|
||||
import { BubbleMenu } from '@tiptap/react/menus';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { common, createLowlight } from 'lowlight';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { Bold, Code2, GripVertical, Italic, Link2, Strikethrough, Underline } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { Doc as YDoc } from 'yjs';
|
||||
|
||||
import {
|
||||
createEditorBlockCommands,
|
||||
isBlockCommandActive,
|
||||
type EditorBlockCommand,
|
||||
} from '@/components/editor-block-commands';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
/** Stable ref for DragHandle `useEffect` deps (avoid re-registering the plugin each render). */
|
||||
const DOCUMENT_EDITOR_DRAG_HANDLE_FLOAT = {
|
||||
placement: 'left-start' as const,
|
||||
strategy: 'absolute' as const,
|
||||
};
|
||||
|
||||
function EditorDragHandle({ editor }: { editor: Editor }) {
|
||||
return (
|
||||
<DragHandle
|
||||
editor={editor}
|
||||
className="fe-document-drag-handle"
|
||||
computePositionConfig={DOCUMENT_EDITOR_DRAG_HANDLE_FLOAT}
|
||||
nested
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="fe-document-drag-handle__grip"
|
||||
aria-label="Drag to reorder block"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<GripVertical className="size-4 shrink-0" aria-hidden />
|
||||
</button>
|
||||
</DragHandle>
|
||||
);
|
||||
}
|
||||
|
||||
function bubbleMenuShouldShow({ editor: ed }: { editor: Editor }) {
|
||||
if (!ed.isEditable) return false;
|
||||
if (ed.state.selection.empty) return false;
|
||||
if (ed.isActive('codeBlock')) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
type MarkUiState = {
|
||||
bold: boolean;
|
||||
italic: boolean;
|
||||
underline: boolean;
|
||||
strike: boolean;
|
||||
code: boolean;
|
||||
link: boolean;
|
||||
};
|
||||
|
||||
function useSetLink(editor: Editor) {
|
||||
return useCallback(() => {
|
||||
const prev = editor.getAttributes('link').href as string | undefined;
|
||||
const href = window.prompt('Link URL', prev ?? 'https://');
|
||||
if (href === null) return;
|
||||
const t = href.trim();
|
||||
if (t === '') {
|
||||
editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().extendMarkRange('link').setLink({ href: t }).run();
|
||||
}, [editor]);
|
||||
}
|
||||
|
||||
function EditorMarkIconButtons({
|
||||
editor,
|
||||
marks,
|
||||
tone,
|
||||
setLink,
|
||||
showDividerBeforeLink = true,
|
||||
}: {
|
||||
editor: Editor;
|
||||
marks: MarkUiState;
|
||||
tone: 'toolbar' | 'bubble';
|
||||
setLink: () => void;
|
||||
showDividerBeforeLink?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<EditorIconButton
|
||||
tone={tone}
|
||||
pressed={marks.bold}
|
||||
label="Bold"
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
>
|
||||
<Bold className="size-3.5" strokeWidth={2.25} aria-hidden />
|
||||
</EditorIconButton>
|
||||
<EditorIconButton
|
||||
tone={tone}
|
||||
pressed={marks.italic}
|
||||
label="Italic"
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
>
|
||||
<Italic className="size-3.5" strokeWidth={2.25} aria-hidden />
|
||||
</EditorIconButton>
|
||||
<EditorIconButton
|
||||
tone={tone}
|
||||
pressed={marks.underline}
|
||||
label="Underline"
|
||||
onClick={() => editor.chain().focus().toggleUnderline().run()}
|
||||
>
|
||||
<Underline className="size-3.5" strokeWidth={2.25} aria-hidden />
|
||||
</EditorIconButton>
|
||||
<EditorIconButton
|
||||
tone={tone}
|
||||
pressed={marks.strike}
|
||||
label="Strikethrough"
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
>
|
||||
<Strikethrough className="size-3.5" strokeWidth={2.25} aria-hidden />
|
||||
</EditorIconButton>
|
||||
<EditorIconButton
|
||||
tone={tone}
|
||||
pressed={marks.code}
|
||||
label="Inline code"
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
>
|
||||
<Code2 className="size-3.5" strokeWidth={2.25} aria-hidden />
|
||||
</EditorIconButton>
|
||||
{showDividerBeforeLink ? (
|
||||
<span className="mx-0.5 hidden h-5 w-px shrink-0 bg-fe-border sm:block" aria-hidden />
|
||||
) : null}
|
||||
<EditorIconButton tone={tone} pressed={marks.link} label="Link" onClick={setLink}>
|
||||
<Link2 className="size-3.5" strokeWidth={2.25} aria-hidden />
|
||||
</EditorIconButton>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorBubbleToolbar({ editor }: { editor: Editor }) {
|
||||
const marks = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: ed }) => ({
|
||||
bold: ed.isActive('bold'),
|
||||
italic: ed.isActive('italic'),
|
||||
underline: ed.isActive('underline'),
|
||||
strike: ed.isActive('strike'),
|
||||
code: ed.isActive('code'),
|
||||
link: ed.isActive('link'),
|
||||
}),
|
||||
});
|
||||
|
||||
const setLink = useSetLink(editor);
|
||||
|
||||
return (
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
shouldShow={bubbleMenuShouldShow}
|
||||
className="flex flex-wrap items-center gap-0.5 rounded-lg border border-fe-border bg-fe-popover px-1 py-1 text-fe-popover-foreground shadow-md"
|
||||
options={{
|
||||
placement: 'top',
|
||||
offset: 10,
|
||||
flip: true,
|
||||
onHide: () => undefined,
|
||||
}}
|
||||
>
|
||||
<EditorMarkIconButtons editor={editor} marks={marks} tone="bubble" setLink={setLink} />
|
||||
</BubbleMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorIconButton({
|
||||
tone,
|
||||
pressed,
|
||||
onClick,
|
||||
label,
|
||||
children,
|
||||
}: {
|
||||
tone: 'toolbar' | 'bubble';
|
||||
pressed: boolean;
|
||||
onClick: () => void;
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={pressed}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex size-8 shrink-0 items-center justify-center rounded-md border border-transparent text-fe-muted-foreground transition-[color,background,box-shadow,transform,border-color] duration-150 ease-out',
|
||||
'hover:border-fe-border/60 hover:bg-fe-muted/80 hover:text-fe-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fe-ring/40 focus-visible:ring-offset-2',
|
||||
tone === 'bubble' && 'focus-visible:ring-offset-fe-popover',
|
||||
tone === 'toolbar' && 'focus-visible:ring-offset-fe-card',
|
||||
'motion-reduce:transition-none motion-reduce:active:scale-100',
|
||||
'active:scale-[0.96]',
|
||||
pressed &&
|
||||
'border-fe-border/50 bg-fe-muted text-fe-foreground shadow-[inset_0_1px_0_0_color-mix(in_oklch,var(--color-fe-foreground)_8%,transparent)]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorBlockToolbar({
|
||||
editor,
|
||||
blockCommands,
|
||||
editable,
|
||||
}: {
|
||||
editor: Editor;
|
||||
blockCommands: EditorBlockCommand[];
|
||||
editable: boolean;
|
||||
}) {
|
||||
const ui = useEditorState({
|
||||
editor,
|
||||
selector: ({ editor: ed }) => {
|
||||
const blocks: Record<string, boolean> = {};
|
||||
for (const c of blockCommands) {
|
||||
blocks[c.id] = isBlockCommandActive(ed, c.id);
|
||||
}
|
||||
return {
|
||||
bold: ed.isActive('bold'),
|
||||
italic: ed.isActive('italic'),
|
||||
underline: ed.isActive('underline'),
|
||||
strike: ed.isActive('strike'),
|
||||
code: ed.isActive('code'),
|
||||
link: ed.isActive('link'),
|
||||
blocks,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const setLink = useSetLink(editor);
|
||||
|
||||
if (!editable) return null;
|
||||
|
||||
const marks: MarkUiState = {
|
||||
bold: ui.bold,
|
||||
italic: ui.italic,
|
||||
underline: ui.underline,
|
||||
strike: ui.strike,
|
||||
code: ui.code,
|
||||
link: ui.link,
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
className="flex flex-wrap items-center gap-y-1.5 border-b border-fe-border bg-fe-secondary/40 px-3 py-1"
|
||||
>
|
||||
<div className="mr-1 flex flex-wrap items-center gap-0.5 border-r border-fe-border pr-2">
|
||||
<EditorMarkIconButtons
|
||||
editor={editor}
|
||||
marks={marks}
|
||||
tone="toolbar"
|
||||
setLink={setLink}
|
||||
showDividerBeforeLink={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-0.5 pl-1">
|
||||
{blockCommands.map((cmd) => {
|
||||
const Icon = cmd.icon;
|
||||
return (
|
||||
<EditorIconButton
|
||||
key={cmd.id}
|
||||
tone="toolbar"
|
||||
pressed={ui.blocks[cmd.id] ?? false}
|
||||
label={cmd.title}
|
||||
onClick={() => cmd.run(editor)}
|
||||
>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
</EditorIconButton>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const lowlight = createLowlight(common);
|
||||
|
||||
export type DocumentEditorUploadHandler = (file: File) => Promise<string>;
|
||||
|
||||
export async function mockDocumentEditorUpload(file: File): Promise<string> {
|
||||
await new Promise((r) => setTimeout(r, 400 + Math.random() * 350));
|
||||
return URL.createObjectURL(file);
|
||||
}
|
||||
|
||||
export interface DocumentEditorProps {
|
||||
className?: string;
|
||||
content?: JSONContent | string;
|
||||
editable?: boolean;
|
||||
/** When set, document syncs through this Y.Doc (e.g. Hocuspocus). Disables local undo history. */
|
||||
collaborationDocument?: YDoc;
|
||||
/** Called after each document update (not on every transaction). */
|
||||
onUpdate?: (payload: { editor: Editor; json: JSONContent }) => void;
|
||||
/** Replace with your API upload; defaults to a delayed object URL. */
|
||||
uploadFile?: DocumentEditorUploadHandler;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
type SlashRect = { left: number; top: number; width: number; height: number };
|
||||
|
||||
type SlashState = { from: number; to: number; query: string; rect: SlashRect };
|
||||
|
||||
function readSlashState(editor: Editor): SlashState | null {
|
||||
const { state } = editor;
|
||||
const { $from } = state.selection;
|
||||
const parent = $from.parent;
|
||||
if (!parent.type.isTextblock || parent.type.name === 'codeBlock') {
|
||||
return null;
|
||||
}
|
||||
const start = $from.start();
|
||||
const end = $from.pos;
|
||||
const text = state.doc.textBetween(start, end, '\0', '\0');
|
||||
const m = text.match(/^(\s*)\/(.*)$/);
|
||||
if (!m) return null;
|
||||
const indent = m[1] ?? '';
|
||||
const slashPos = start + indent.length;
|
||||
const query = m[2] ?? '';
|
||||
const coords = editor.view.coordsAtPos(end);
|
||||
return {
|
||||
from: slashPos,
|
||||
to: end,
|
||||
query,
|
||||
rect: {
|
||||
left: coords.left,
|
||||
top: coords.bottom + window.scrollY,
|
||||
width: 0,
|
||||
height: coords.bottom - coords.top,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type SlashItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
keywords: string[];
|
||||
icon: LucideIcon;
|
||||
run: (editor: Editor, range: { from: number; to: number }) => void;
|
||||
};
|
||||
|
||||
function blockCommandToSlashItem(cmd: EditorBlockCommand): SlashItem {
|
||||
return {
|
||||
id: cmd.id,
|
||||
title: cmd.title,
|
||||
subtitle: cmd.subtitle,
|
||||
keywords: cmd.keywords,
|
||||
icon: cmd.icon,
|
||||
run: (ed, range) => cmd.runFromSlash(ed, range),
|
||||
};
|
||||
}
|
||||
|
||||
function useSlashMenuPosition(rect: SlashRect) {
|
||||
return useMemo(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return { left: rect.left, top: rect.top + 8 };
|
||||
}
|
||||
const panelW = 280;
|
||||
const margin = 12;
|
||||
const left = Math.min(Math.max(margin, rect.left), window.innerWidth - panelW - margin);
|
||||
const top = rect.top + 8;
|
||||
return { left, top };
|
||||
}, [rect.left, rect.top]);
|
||||
}
|
||||
|
||||
function SlashMenuPanel({
|
||||
editor,
|
||||
state,
|
||||
items,
|
||||
selectedIndex,
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
editor: Editor;
|
||||
state: SlashState;
|
||||
items: SlashItem[];
|
||||
selectedIndex: number;
|
||||
onPick: (item: SlashItem) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const pos = useSlashMenuPosition(state.rect);
|
||||
|
||||
useEffect(() => {
|
||||
const el = listRef.current?.querySelector<HTMLElement>(`[data-index="${selectedIndex}"]`);
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}, [selectedIndex]);
|
||||
|
||||
const onKey = useEffectEvent((e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
editor.chain().focus().run();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('keydown', onKey, true);
|
||||
return () => window.removeEventListener('keydown', onKey, true);
|
||||
}, [editor]);
|
||||
|
||||
const panelClass =
|
||||
'fe-slash-menu pointer-events-auto w-[17.5rem] max-w-[min(17.5rem,calc(100vw-1.5rem))] overflow-hidden rounded-lg border border-fe-border bg-fe-popover text-fe-popover-foreground shadow-lg';
|
||||
|
||||
const empty = (
|
||||
<div
|
||||
className={cn(panelClass, 'py-6')}
|
||||
style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 200 }}
|
||||
role="dialog"
|
||||
aria-label="Insert block"
|
||||
>
|
||||
<p className="px-4 text-center text-[0.8125rem] text-fe-muted-foreground">
|
||||
No matching blocks
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const menu = (
|
||||
<div
|
||||
className={panelClass}
|
||||
style={{ position: 'fixed', left: pos.left, top: pos.top, zIndex: 200 }}
|
||||
role="presentation"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-fe-border/60 px-3 py-2">
|
||||
<span className="text-[0.65rem] font-semibold uppercase tracking-[0.12em] text-fe-muted-foreground">
|
||||
Insert
|
||||
</span>
|
||||
<span className="text-[0.65rem] tabular-nums text-fe-muted-foreground/80">
|
||||
<kbd className="rounded border border-fe-border/80 bg-fe-muted/50 px-1 py-px font-mono text-[0.6rem]">
|
||||
↑↓
|
||||
</kbd>
|
||||
<span className="mx-1 opacity-50">·</span>
|
||||
<kbd className="rounded border border-fe-border/80 bg-fe-muted/50 px-1 py-px font-mono text-[0.6rem]">
|
||||
↵
|
||||
</kbd>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
ref={listRef}
|
||||
className="max-h-[min(19rem,52vh)] overflow-y-auto overscroll-contain px-1.5 py-1.5"
|
||||
role="listbox"
|
||||
aria-label="Insert block"
|
||||
aria-activedescendant={`slash-item-${items[selectedIndex]?.id}`}
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
const Icon = item.icon;
|
||||
const active = i === selectedIndex;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
id={`slash-item-${item.id}`}
|
||||
data-index={i}
|
||||
role="option"
|
||||
aria-selected={active}
|
||||
className={cn(
|
||||
'fe-slash-row flex min-h-11 w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-[background,box-shadow,color] duration-150 ease-out',
|
||||
active
|
||||
? 'bg-fe-accent/90 text-fe-accent-foreground shadow-[inset_0_1px_0_0_color-mix(in_oklch,var(--color-fe-foreground)_10%,transparent)]'
|
||||
: 'text-fe-card-foreground hover:bg-fe-muted/70',
|
||||
)}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onPick(item)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'flex size-9 shrink-0 items-center justify-center rounded-lg border transition-[border-color,background] duration-150 motion-reduce:transition-none',
|
||||
active
|
||||
? 'border-fe-border/40 bg-fe-card/35'
|
||||
: 'border-fe-border/55 bg-fe-secondary/80',
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'size-4',
|
||||
active ? 'text-fe-accent-foreground/90' : 'text-fe-muted-foreground',
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[0.8125rem] font-medium leading-tight">
|
||||
{item.title}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 block text-[0.7rem] leading-snug',
|
||||
active ? 'text-fe-accent-foreground/75' : 'text-fe-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{item.subtitle}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!editor) return null;
|
||||
|
||||
return createPortal(items.length === 0 ? empty : menu, document.body);
|
||||
}
|
||||
|
||||
function filterSlashItems(items: SlashItem[], query: string): SlashItem[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter((item) => {
|
||||
const hay = `${item.title} ${item.subtitle} ${item.keywords.join(' ')}`.toLowerCase();
|
||||
return hay.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
const emptyDoc: JSONContent = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
|
||||
export function DocumentEditor({
|
||||
className,
|
||||
content,
|
||||
editable = true,
|
||||
collaborationDocument,
|
||||
onUpdate,
|
||||
uploadFile = mockDocumentEditorUpload,
|
||||
autoFocus = false,
|
||||
}: DocumentEditorProps) {
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const slashIndexRef = useRef(0);
|
||||
const onUpdateRef = useRef(onUpdate);
|
||||
const uploadFileRef = useRef(uploadFile);
|
||||
const prevSlashRef = useRef<SlashState | null>(null);
|
||||
const [slash, setSlash] = useState<SlashState | null>(null);
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
|
||||
onUpdateRef.current = onUpdate;
|
||||
uploadFileRef.current = uploadFile;
|
||||
slashIndexRef.current = slashIndex;
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
const kit = StarterKit.configure({
|
||||
codeBlock: false,
|
||||
...(collaborationDocument ? { undoRedo: false as const } : {}),
|
||||
dropcursor: { color: 'var(--color-fe-ring)', width: 2 },
|
||||
});
|
||||
const collab = collaborationDocument
|
||||
? Collaboration.configure({ document: collaborationDocument })
|
||||
: null;
|
||||
const rest = [
|
||||
NodeRange,
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight,
|
||||
defaultLanguage: 'typescript',
|
||||
HTMLAttributes: { class: 'hljs' },
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder({ node }) {
|
||||
if (node.type.name === 'heading') return 'Heading';
|
||||
return 'Start writing, or type / for blocks…';
|
||||
},
|
||||
showOnlyWhenEditable: true,
|
||||
showOnlyCurrent: true,
|
||||
}),
|
||||
Image.configure({
|
||||
inline: false,
|
||||
allowBase64: true,
|
||||
HTMLAttributes: {
|
||||
class: 'fe-doc-img',
|
||||
},
|
||||
}),
|
||||
Table.configure({
|
||||
resizable: true,
|
||||
HTMLAttributes: { class: 'fe-doc-table' },
|
||||
}),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
];
|
||||
return collab ? [kit, collab, ...rest] : [kit, ...rest];
|
||||
}, [collaborationDocument]);
|
||||
|
||||
const requestImagePick = useCallback(() => {
|
||||
requestAnimationFrame(() => fileRef.current?.click());
|
||||
}, []);
|
||||
|
||||
const blockCommands = useMemo(
|
||||
() => createEditorBlockCommands(requestImagePick),
|
||||
[requestImagePick],
|
||||
);
|
||||
|
||||
const slashItems = useMemo(() => blockCommands.map(blockCommandToSlashItem), [blockCommands]);
|
||||
|
||||
const handleImageFiles = useCallback(async (list: FileList | null) => {
|
||||
const ed = editorRef.current;
|
||||
const file = list?.[0];
|
||||
if (!ed || !file || !file.type.startsWith('image/')) return;
|
||||
const src = await uploadFileRef.current(file);
|
||||
ed.chain().focus().setImage({ src, alt: file.name }).run();
|
||||
}, []);
|
||||
|
||||
const initialContent = collaborationDocument
|
||||
? typeof content === 'string'
|
||||
? content
|
||||
: ''
|
||||
: (content ?? emptyDoc);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
immediatelyRender: false,
|
||||
extensions,
|
||||
content: initialContent,
|
||||
editable,
|
||||
autofocus: autoFocus ? 'end' : false,
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class: 'outline-none',
|
||||
spellcheck: 'true',
|
||||
},
|
||||
handleKeyDown(_view, event) {
|
||||
const ed = editorRef.current;
|
||||
if (!ed) return false;
|
||||
const panel = readSlashState(ed);
|
||||
if (!panel) return false;
|
||||
|
||||
if (event.key === 'ArrowDown') {
|
||||
const filtered = filterSlashItems(slashItems, panel.query);
|
||||
if (filtered.length === 0) return false;
|
||||
event.preventDefault();
|
||||
const next = Math.min(slashIndexRef.current + 1, filtered.length - 1);
|
||||
slashIndexRef.current = next;
|
||||
setSlashIndex(next);
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
const filtered = filterSlashItems(slashItems, panel.query);
|
||||
if (filtered.length === 0) return false;
|
||||
event.preventDefault();
|
||||
const next = Math.max(slashIndexRef.current - 1, 0);
|
||||
slashIndexRef.current = next;
|
||||
setSlashIndex(next);
|
||||
return true;
|
||||
}
|
||||
if (event.key === 'Enter') {
|
||||
const filtered = filterSlashItems(slashItems, panel.query);
|
||||
const item = filtered[slashIndexRef.current];
|
||||
if (!item) return false;
|
||||
event.preventDefault();
|
||||
item.run(ed, { from: panel.from, to: panel.to });
|
||||
setSlash(null);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleDrop(view, event) {
|
||||
const ed = editorRef.current;
|
||||
if (!ed || !event.dataTransfer?.files?.length) return false;
|
||||
const file = event.dataTransfer.files[0];
|
||||
if (!file?.type.startsWith('image/')) return false;
|
||||
event.preventDefault();
|
||||
void (async () => {
|
||||
const src = await uploadFileRef.current(file);
|
||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })?.pos;
|
||||
if (pos == null) return;
|
||||
ed.chain()
|
||||
.focus()
|
||||
.insertContentAt(pos, { type: 'image', attrs: { src, alt: file.name } })
|
||||
.run();
|
||||
})();
|
||||
return true;
|
||||
},
|
||||
handlePaste(_view, event) {
|
||||
const ed = editorRef.current;
|
||||
const items = event.clipboardData?.items;
|
||||
if (!ed || !items) return false;
|
||||
for (const item of items) {
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (!file) continue;
|
||||
event.preventDefault();
|
||||
void (async () => {
|
||||
const src = await uploadFileRef.current(file);
|
||||
ed.chain().focus().setImage({ src, alt: file.name }).run();
|
||||
})();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
onUpdateRef.current?.({ editor: ed, json: ed.getJSON() });
|
||||
},
|
||||
},
|
||||
[extensions],
|
||||
);
|
||||
|
||||
editorRef.current = editor;
|
||||
|
||||
const syncSlash = useEffectEvent(() => {
|
||||
const s = readSlashState(editor!);
|
||||
const prev = prevSlashRef.current;
|
||||
|
||||
if (!s) {
|
||||
if (prev === null) return;
|
||||
prevSlashRef.current = null;
|
||||
setSlash(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${s.from}|${s.to}|${s.query}`;
|
||||
const prevKey = prev ? `${prev.from}|${prev.to}|${prev.query}` : null;
|
||||
|
||||
if (key === prevKey && prev !== null) {
|
||||
const rectMoved =
|
||||
Math.abs(prev.rect.left - s.rect.left) > 1 || Math.abs(prev.rect.top - s.rect.top) > 1;
|
||||
if (!rectMoved) return;
|
||||
prevSlashRef.current = s;
|
||||
setSlash(s);
|
||||
return;
|
||||
}
|
||||
|
||||
prevSlashRef.current = s;
|
||||
setSlash(s);
|
||||
if (!prev) {
|
||||
slashIndexRef.current = 0;
|
||||
setSlashIndex(0);
|
||||
}
|
||||
const filtered = filterSlashItems(slashItems, s.query);
|
||||
setSlashIndex((i) => {
|
||||
const next = Math.min(i, Math.max(0, filtered.length - 1));
|
||||
slashIndexRef.current = next;
|
||||
return next;
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
|
||||
editor.on('transaction', syncSlash);
|
||||
editor.on('selectionUpdate', syncSlash);
|
||||
syncSlash();
|
||||
return () => {
|
||||
editor.off('transaction', syncSlash);
|
||||
editor.off('selectionUpdate', syncSlash);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || content === undefined || collaborationDocument) return;
|
||||
const cur = JSON.stringify(editor.getJSON());
|
||||
const next = typeof content === 'string' ? content : JSON.stringify(content);
|
||||
if (cur !== next) {
|
||||
editor.commands.setContent(content, { emitUpdate: false });
|
||||
}
|
||||
}, [editor, content, collaborationDocument]);
|
||||
|
||||
if (editor && editor.isEditable !== editable) {
|
||||
editor.setEditable(editable);
|
||||
}
|
||||
|
||||
const filteredSlash = useMemo(
|
||||
() => (slash ? filterSlashItems(slashItems, slash.query) : []),
|
||||
[slash, slashItems],
|
||||
);
|
||||
|
||||
const pickSlash = useCallback(
|
||||
(item: SlashItem) => {
|
||||
if (!editor || !slash) return;
|
||||
item.run(editor, { from: slash.from, to: slash.to });
|
||||
setSlash(null);
|
||||
},
|
||||
[editor, slash],
|
||||
);
|
||||
|
||||
const closeSlash = useCallback(() => setSlash(null), []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'fe-document-editor group relative flex flex-col overflow-hidden rounded-lg border border-fe-border bg-fe-card text-fe-card-foreground',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="sr-only"
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
onChange={(e) => {
|
||||
void handleImageFiles(e.target.files);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
|
||||
{editor ? (
|
||||
<EditorBlockToolbar editor={editor} blockCommands={blockCommands} editable={editable} />
|
||||
) : null}
|
||||
|
||||
{editor ? <EditorBubbleToolbar editor={editor} /> : null}
|
||||
|
||||
{editor && slash ? (
|
||||
<SlashMenuPanel
|
||||
editor={editor}
|
||||
state={slash}
|
||||
items={filteredSlash}
|
||||
selectedIndex={Math.min(slashIndex, Math.max(0, filteredSlash.length - 1))}
|
||||
onPick={pickSlash}
|
||||
onClose={closeSlash}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col fe-document-editor__canvas flex-1',
|
||||
editable && editor && 'fe-document-editor__canvas--draggable',
|
||||
)}
|
||||
>
|
||||
{editor && editable ? <EditorDragHandle editor={editor} /> : null}
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="flex flex-col fe-document-editor__prose flex-1 text-fe-card-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { Editor } from '@tiptap/react';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
Code2,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
ImageIcon,
|
||||
List,
|
||||
ListOrdered,
|
||||
Minus,
|
||||
Pilcrow,
|
||||
Quote,
|
||||
Table2,
|
||||
} from 'lucide-react';
|
||||
|
||||
export type SlashRange = { from: number; to: number };
|
||||
|
||||
/**
|
||||
* Block insert/transform commands shared by the slash menu and top toolbar.
|
||||
* `runFromSlash` removes the `/…` range first; `run` applies at the current cursor.
|
||||
*/
|
||||
export interface EditorBlockCommand {
|
||||
id: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
keywords: string[];
|
||||
icon: LucideIcon;
|
||||
runFromSlash: (ed: Editor, range: SlashRange) => void;
|
||||
run: (ed: Editor) => void;
|
||||
}
|
||||
|
||||
function afterDelete(ed: Editor, range: SlashRange): ReturnType<Editor['chain']> {
|
||||
return ed.chain().focus().deleteRange(range);
|
||||
}
|
||||
|
||||
export function createEditorBlockCommands(requestImagePick: () => void): EditorBlockCommand[] {
|
||||
return [
|
||||
{
|
||||
id: 'text',
|
||||
title: 'Text',
|
||||
subtitle: 'Plain paragraph',
|
||||
keywords: ['paragraph', 'p', 'text'],
|
||||
icon: Pilcrow,
|
||||
runFromSlash: (ed, range) => {
|
||||
afterDelete(ed, range).setParagraph().run();
|
||||
},
|
||||
run: (ed) => ed.chain().focus().setParagraph().run(),
|
||||
},
|
||||
{
|
||||
id: 'h1',
|
||||
title: 'Heading 1',
|
||||
subtitle: 'Large section title',
|
||||
keywords: ['title', 'h1'],
|
||||
icon: Heading1,
|
||||
runFromSlash: (ed, range) => {
|
||||
afterDelete(ed, range).setHeading({ level: 1 }).run();
|
||||
},
|
||||
run: (ed) => ed.chain().focus().setHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
id: 'h2',
|
||||
title: 'Heading 2',
|
||||
subtitle: 'Medium section title',
|
||||
keywords: ['subtitle', 'h2'],
|
||||
icon: Heading2,
|
||||
runFromSlash: (ed, range) => {
|
||||
afterDelete(ed, range).setHeading({ level: 2 }).run();
|
||||
},
|
||||
run: (ed) => ed.chain().focus().setHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
id: 'h3',
|
||||
title: 'Heading 3',
|
||||
subtitle: 'Small section title',
|
||||
keywords: ['h3'],
|
||||
icon: Heading3,
|
||||
runFromSlash: (ed, range) => {
|
||||
afterDelete(ed, range).setHeading({ level: 3 }).run();
|
||||
},
|
||||
run: (ed) => ed.chain().focus().setHeading({ level: 3 }).run(),
|
||||
},
|
||||
{
|
||||
id: 'bullet',
|
||||
title: 'Bulleted list',
|
||||
subtitle: 'Unordered list',
|
||||
keywords: ['ul', 'bullet'],
|
||||
icon: List,
|
||||
runFromSlash: (ed, range) => {
|
||||
afterDelete(ed, range).toggleBulletList().run();
|
||||
},
|
||||
run: (ed) => ed.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
id: 'ordered',
|
||||
title: 'Numbered list',
|
||||
subtitle: 'Ordered list',
|
||||
keywords: ['ol', 'number'],
|
||||
icon: ListOrdered,
|
||||
runFromSlash: (ed, range) => {
|
||||
afterDelete(ed, range).toggleOrderedList().run();
|
||||
},
|
||||
run: (ed) => ed.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
id: 'quote',
|
||||
title: 'Quote',
|
||||
subtitle: 'Callout block',
|
||||
keywords: ['blockquote'],
|
||||
icon: Quote,
|
||||
runFromSlash: (ed, range) => {
|
||||
afterDelete(ed, range).toggleBlockquote().run();
|
||||
},
|
||||
run: (ed) => ed.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
id: 'code',
|
||||
title: 'Code block',
|
||||
subtitle: 'Syntax highlighting',
|
||||
keywords: ['snippet'],
|
||||
icon: Code2,
|
||||
runFromSlash: (ed, range) => {
|
||||
afterDelete(ed, range).toggleCodeBlock().run();
|
||||
},
|
||||
run: (ed) => ed.chain().focus().toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
id: 'divider',
|
||||
title: 'Divider',
|
||||
subtitle: 'Horizontal rule',
|
||||
keywords: ['hr', 'line', 'separator'],
|
||||
icon: Minus,
|
||||
runFromSlash: (ed, range) => {
|
||||
afterDelete(ed, range).setHorizontalRule().run();
|
||||
},
|
||||
run: (ed) => ed.chain().focus().setHorizontalRule().run(),
|
||||
},
|
||||
{
|
||||
id: 'table',
|
||||
title: 'Table',
|
||||
subtitle: '3×3 with header',
|
||||
keywords: ['grid'],
|
||||
icon: Table2,
|
||||
runFromSlash: (ed, range) => {
|
||||
afterDelete(ed, range).insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run();
|
||||
},
|
||||
run: (ed) => ed.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
|
||||
},
|
||||
{
|
||||
id: 'image',
|
||||
title: 'Image',
|
||||
subtitle: 'Upload from device (mock)',
|
||||
keywords: ['photo', 'picture', 'upload'],
|
||||
icon: ImageIcon,
|
||||
runFromSlash: (ed, range) => {
|
||||
ed.chain().focus().deleteRange(range).run();
|
||||
requestImagePick();
|
||||
},
|
||||
run: (ed) => {
|
||||
ed.chain().focus().run();
|
||||
requestImagePick();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function isBlockCommandActive(ed: Editor, id: string): boolean {
|
||||
switch (id) {
|
||||
case 'text':
|
||||
return (
|
||||
ed.isActive('paragraph') &&
|
||||
!ed.isActive('heading') &&
|
||||
!ed.isActive('bulletList') &&
|
||||
!ed.isActive('orderedList') &&
|
||||
!ed.isActive('blockquote') &&
|
||||
!ed.isActive('codeBlock')
|
||||
);
|
||||
case 'h1':
|
||||
return ed.isActive('heading', { level: 1 });
|
||||
case 'h2':
|
||||
return ed.isActive('heading', { level: 2 });
|
||||
case 'h3':
|
||||
return ed.isActive('heading', { level: 3 });
|
||||
case 'bullet':
|
||||
return ed.isActive('bulletList');
|
||||
case 'ordered':
|
||||
return ed.isActive('orderedList');
|
||||
case 'quote':
|
||||
return ed.isActive('blockquote');
|
||||
case 'code':
|
||||
return ed.isActive('codeBlock');
|
||||
case 'table':
|
||||
return ed.isActive('table');
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2 py-0.5 text-xs font-medium tabular-nums',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
success: 'border-fe-success/60 bg-fe-success/10 text-fe-success',
|
||||
info: 'border-fe-info/60 bg-fe-info/10 text-fe-info',
|
||||
destructive: 'border-fe-destructive/60 bg-fe-destructive/10 text-fe-destructive',
|
||||
warning: 'border-fe-warning/60 bg-fe-warning/10 text-fe-warning',
|
||||
muted: 'border-fe-border bg-fe-muted text-fe-muted-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'muted',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export type BadgeVariant = NonNullable<VariantProps<typeof badgeVariants>['variant']>;
|
||||
|
||||
export type CollabConnectionStatus = 'connecting' | 'connected' | 'disconnected' | 'error';
|
||||
|
||||
export function badgeVariantForCollabConnection(status: CollabConnectionStatus): BadgeVariant {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return 'success';
|
||||
case 'connecting':
|
||||
return 'info';
|
||||
case 'error':
|
||||
return 'destructive';
|
||||
case 'disconnected':
|
||||
return 'warning';
|
||||
}
|
||||
}
|
||||
|
||||
interface BadgeProps extends ComponentPropsWithoutRef<'span'>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
export function Badge({ variant = 'muted', className, ...props }: BadgeProps) {
|
||||
return <span className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { ButtonHTMLAttributes } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export const buttonVariants = cva(
|
||||
'rounded-md px-2.5 py-1.5 text-xs font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-60',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
secondary:
|
||||
'border border-fe-border bg-fe-secondary text-fe-secondary-foreground hover:bg-fe-accent',
|
||||
primary: 'bg-fe-primary text-fe-primary-foreground hover:opacity-90',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'secondary',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface ButtonProps
|
||||
extends ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
|
||||
|
||||
export function Button({ variant = 'secondary', className, ...props }: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type={props.type ?? 'button'}
|
||||
className={cn(buttonVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const cardVariants = cva('rounded-lg border border-fe-border', {
|
||||
variants: {
|
||||
tone: {
|
||||
default: 'bg-fe-card text-fe-card-foreground',
|
||||
muted: 'bg-fe-muted text-fe-muted-foreground',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
tone: 'default',
|
||||
},
|
||||
});
|
||||
|
||||
interface CardProps extends ComponentPropsWithoutRef<'div'>, VariantProps<typeof cardVariants> {}
|
||||
|
||||
export function Card({ tone = 'default', className, ...props }: CardProps) {
|
||||
return <div className={cn(cardVariants({ tone }), className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { Checkbox as Primitive } from '@base-ui/react/checkbox';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cnState } from '@/lib/cn';
|
||||
import { CheckIcon } from 'lucide-react';
|
||||
|
||||
const checkboxRootStyles = cva(
|
||||
'grid size-4 shrink-0 place-items-center rounded border border-fe-border bg-fe-input text-fe-foreground outline-none hover:border-fe-ring focus-visible:border-fe-ring data-checked:bg-fe-primary data-checked:text-fe-primary-foreground disabled:cursor-not-allowed disabled:opacity-60',
|
||||
);
|
||||
|
||||
const checkboxIndicatorStyles = cva('grid place-items-center');
|
||||
|
||||
export type CheckboxProps = ComponentPropsWithoutRef<typeof Primitive.Root>;
|
||||
|
||||
export function Checkbox({ className, children, ...props }: CheckboxProps) {
|
||||
return (
|
||||
<Primitive.Root className={cnState(checkboxRootStyles(), className)} {...props}>
|
||||
{children ?? (
|
||||
<Primitive.Indicator className={checkboxIndicatorStyles()}>
|
||||
<CheckIcon className="size-3.5" strokeWidth="4" />
|
||||
</Primitive.Indicator>
|
||||
)}
|
||||
</Primitive.Root>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
const inputBase = cva(
|
||||
'rounded-md border border-fe-border bg-fe-input px-3 py-2 text-sm text-fe-foreground outline-none placeholder:text-fe-muted-foreground focus:border-fe-ring',
|
||||
);
|
||||
|
||||
export function Input({ className, ...props }: ComponentPropsWithoutRef<'input'>) {
|
||||
return <input className={cn(inputBase(), className)} {...props} />;
|
||||
}
|
||||
|
||||
export function Textarea({ className, ...props }: ComponentPropsWithoutRef<'textarea'>) {
|
||||
return <textarea className={cn(inputBase(), className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { Popover } from '@base-ui/react/popover';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
import { cn } from '@/lib/cn';
|
||||
|
||||
export const PopoverRoot = Popover.Root;
|
||||
export const PopoverPortal = Popover.Portal;
|
||||
export const PopoverPositioner = Popover.Positioner;
|
||||
export const PopoverTrigger = Popover.Trigger;
|
||||
export const PopoverClose = Popover.Close;
|
||||
|
||||
const popoverContentVariants = cva(
|
||||
'z-30 rounded-lg border border-fe-border bg-fe-popover p-3 text-fe-popover-foreground shadow-2xl',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
sm: 'w-72',
|
||||
md: 'w-[360px]',
|
||||
lg: 'w-[420px]',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
interface PopoverContentProps
|
||||
extends
|
||||
Omit<ComponentPropsWithoutRef<typeof Popover.Popup>, 'className'>,
|
||||
VariantProps<typeof popoverContentVariants> {}
|
||||
interface PopoverContentWithClassNameProps extends PopoverContentProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function PopoverContent({
|
||||
size = 'md',
|
||||
className,
|
||||
...props
|
||||
}: PopoverContentWithClassNameProps) {
|
||||
return <Popover.Popup className={cn(popoverContentVariants({ size }), className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import type { ComponentPropsWithoutRef } from 'react';
|
||||
import { Select as Primitive } from '@base-ui/react/select';
|
||||
import { cva } from 'class-variance-authority';
|
||||
import { cnState } from '@/lib/cn';
|
||||
import { CheckIcon, ChevronDown } from 'lucide-react';
|
||||
|
||||
export const Select = Primitive.Root;
|
||||
export const SelectValue = Primitive.Value;
|
||||
export const SelectIcon = Primitive.Icon;
|
||||
export const SelectList = Primitive.List;
|
||||
export const SelectItemText = Primitive.ItemText;
|
||||
|
||||
const selectTriggerStyles = cva(
|
||||
'flex w-full text-start items-center justify-between rounded-md border border-fe-border bg-fe-input px-3 py-2 text-sm text-fe-foreground outline-none transition-colors hover:border-fe-ring focus-visible:border-fe-ring',
|
||||
);
|
||||
|
||||
const selectPopupStyles = cva(
|
||||
'z-50 min-w-(--anchor-width) rounded-md border border-fe-border bg-fe-popover p-1 text-fe-popover-foreground shadow-2xl',
|
||||
);
|
||||
|
||||
const selectItemStyles = cva(
|
||||
'flex cursor-default select-none items-center rounded px-2 py-1.5 text-sm text-fe-muted-foreground outline-none data-highlighted:bg-fe-accent data-highlighted:text-fe-accent-foreground data-selected:text-fe-foreground',
|
||||
);
|
||||
|
||||
type SelectTriggerProps = ComponentPropsWithoutRef<typeof Primitive.Trigger>;
|
||||
type SelectPopupProps = ComponentPropsWithoutRef<typeof Primitive.Popup>;
|
||||
type SelectPositionerProps = ComponentPropsWithoutRef<typeof Primitive.Positioner>;
|
||||
type SelectItemProps = ComponentPropsWithoutRef<typeof Primitive.Item>;
|
||||
|
||||
export function SelectTrigger({ className, children, ...props }: SelectTriggerProps) {
|
||||
return (
|
||||
<Primitive.Trigger className={cnState(selectTriggerStyles(), className)} {...props}>
|
||||
{children}
|
||||
<SelectIcon className="size-3.5 text-fe-muted-foreground" render={<ChevronDown />} />
|
||||
</Primitive.Trigger>
|
||||
);
|
||||
}
|
||||
|
||||
interface SelectContentProps extends SelectPopupProps {
|
||||
sideOffset?: number;
|
||||
align?: SelectPositionerProps['align'];
|
||||
}
|
||||
|
||||
export function SelectContent({ sideOffset = 6, align, className, ...props }: SelectContentProps) {
|
||||
return (
|
||||
<Primitive.Portal>
|
||||
<Primitive.Positioner sideOffset={sideOffset} align={align}>
|
||||
<Primitive.Popup className={cnState(selectPopupStyles(), className)} {...props} />
|
||||
</Primitive.Positioner>
|
||||
</Primitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export function SelectItem({ className, children, ...props }: SelectItemProps) {
|
||||
return (
|
||||
<Primitive.Item className={cnState(selectItemStyles(), className)} {...props}>
|
||||
{children}
|
||||
<Primitive.ItemIndicator className="ms-auto size-3.5" render={<CheckIcon />} />
|
||||
</Primitive.Item>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { twMerge as cn } from 'tailwind-merge';
|
||||
|
||||
export { cn };
|
||||
|
||||
export function cnState<TState>(
|
||||
base: string,
|
||||
className: string | ((state: TState) => string | undefined) | undefined,
|
||||
) {
|
||||
if (!className) return base;
|
||||
if (typeof className === 'function') {
|
||||
return (state: TState) => cn(base, className(state));
|
||||
}
|
||||
return cn(base, className);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "dashboard",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "oxlint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.3.0",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.22.0",
|
||||
"@tiptap/extension-collaboration": "^3.22.0",
|
||||
"@tiptap/extension-drag-handle": "^3.22.0",
|
||||
"@tiptap/extension-drag-handle-react": "^3.22.0",
|
||||
"@tiptap/extension-image": "^3.22.0",
|
||||
"@tiptap/extension-node-range": "^3.22.0",
|
||||
"@tiptap/extension-placeholder": "^3.22.0",
|
||||
"@tiptap/extension-table": "^3.22.0",
|
||||
"@tiptap/extension-table-cell": "^3.22.0",
|
||||
"@tiptap/extension-table-header": "^3.22.0",
|
||||
"@tiptap/extension-table-row": "^3.22.0",
|
||||
"@tiptap/react": "^3.22.0",
|
||||
"@tiptap/starter-kit": "^3.22.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lowlight": "^3.3.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"next": "16.2.4",
|
||||
"react": "19.2.5",
|
||||
"react-dom": "19.2.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"yjs": "^13.6.30"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@@ -45,7 +45,7 @@
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"hast-util-to-jsx-runtime": "^2.3.6",
|
||||
"katex": "0.16.45",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"mermaid": "^11.14.0",
|
||||
"motion": "^12.38.0",
|
||||
"next": "16.2.4",
|
||||
@@ -88,7 +88,7 @@
|
||||
"mcp-handler": "^1.1.0",
|
||||
"next-validate-link": "^1.6.5",
|
||||
"postcss": "^8.5.10",
|
||||
"shadcn": "4.4.0",
|
||||
"shadcn": "4.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tinyexec": "^1.1.1",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@fumadocs/base-ui": "workspace:*",
|
||||
"fumadocs-core": "workspace:*",
|
||||
"fumadocs-mdx": "workspace:*",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"next": "16.2.4",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"@fumadocs/local-md": "workspace:*",
|
||||
"fumadocs-core": "workspace:*",
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"next": "16.2.4",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"fumadocs-core": "workspace:*",
|
||||
"fumadocs-mdx": "workspace:*",
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"next": "16.2.4",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"fumadocs-core": "workspace:*",
|
||||
"fumadocs-mdx": "workspace:*",
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"next": "16.2.4",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"fumadocs-mdx": "workspace:*",
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"isbot": "^5.1.39",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router": "^7.14.2",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"fumadocs-mdx": "workspace:*",
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"isbot": "^5.1.39",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router": "^7.14.2",
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@orama/tokenizers": "^3.1.18",
|
||||
"@tanstack/react-router": "1.168.23",
|
||||
"@tanstack/react-router": "1.168.24",
|
||||
"@tanstack/react-router-devtools": "1.166.13",
|
||||
"@tanstack/react-start": "1.167.42",
|
||||
"@tanstack/react-start": "1.167.48",
|
||||
"fumadocs-core": "workspace:*",
|
||||
"fumadocs-mdx": "workspace:*",
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"lucide-static": "^1.9.0",
|
||||
"lucide-static": "^1.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fumadocs/local-md": "workspace:*",
|
||||
"@tanstack/react-router": "1.168.23",
|
||||
"@tanstack/react-router": "1.168.24",
|
||||
"@tanstack/react-router-devtools": "1.166.13",
|
||||
"@tanstack/react-start": "1.167.42",
|
||||
"@tanstack/react-start": "1.167.48",
|
||||
"fumadocs-core": "workspace:*",
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
"types:check": "fumadocs-mdx && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "1.168.23",
|
||||
"@tanstack/react-router": "1.168.24",
|
||||
"@tanstack/react-router-devtools": "1.166.13",
|
||||
"@tanstack/react-start": "1.167.42",
|
||||
"@tanstack/react-start": "1.167.48",
|
||||
"fumadocs-core": "workspace:*",
|
||||
"fumadocs-mdx": "workspace:*",
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"vite": "^8.0.10"
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
"types:check": "fumadocs-mdx && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "1.168.23",
|
||||
"@tanstack/react-router": "1.168.24",
|
||||
"@tanstack/react-router-devtools": "1.166.13",
|
||||
"@tanstack/react-start": "1.167.42",
|
||||
"@tanstack/react-start": "1.167.48",
|
||||
"fumadocs-core": "workspace:*",
|
||||
"fumadocs-mdx": "workspace:*",
|
||||
"fumadocs-openapi": "workspace:*",
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"shiki": "^4.0.2",
|
||||
|
||||
@@ -12,14 +12,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@orama/orama": "^3.1.18",
|
||||
"@tanstack/react-router": "1.168.23",
|
||||
"@tanstack/react-router": "1.168.24",
|
||||
"@tanstack/react-router-devtools": "1.166.13",
|
||||
"@tanstack/react-start": "1.167.42",
|
||||
"@tanstack/start-static-server-functions": "1.166.33",
|
||||
"@tanstack/react-start": "1.167.48",
|
||||
"@tanstack/start-static-server-functions": "1.166.35",
|
||||
"fumadocs-core": "workspace:*",
|
||||
"fumadocs-mdx": "workspace:*",
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fumadocs/story": "workspace:*",
|
||||
"@tanstack/react-router": "1.168.23",
|
||||
"@tanstack/react-router": "1.168.24",
|
||||
"@tanstack/react-router-devtools": "1.166.13",
|
||||
"@tanstack/react-start": "1.167.42",
|
||||
"@tanstack/react-start": "1.167.48",
|
||||
"fumadocs-core": "workspace:*",
|
||||
"fumadocs-mdx": "workspace:*",
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
"types:check": "fumadocs-mdx && tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-router": "1.168.23",
|
||||
"@tanstack/react-router": "1.168.24",
|
||||
"@tanstack/react-router-devtools": "1.166.13",
|
||||
"@tanstack/react-start": "1.167.42",
|
||||
"@tanstack/react-start": "1.167.48",
|
||||
"fumadocs-core": "workspace:*",
|
||||
"fumadocs-mdx": "workspace:*",
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"fumadocs-core": "workspace:*",
|
||||
"fumadocs-mdx": "workspace:*",
|
||||
"fumadocs-ui": "workspace:*",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-server-dom-webpack": "^19.2.5",
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"@base-ui/react": "^1.4.1",
|
||||
"@fumadocs/tailwind": "workspace:*",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"motion": "^12.38.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react-remove-scroll": "^2.7.2",
|
||||
|
||||
@@ -120,13 +120,13 @@
|
||||
"vfile": "^6.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formatjs/intl-localematcher": "^0.8.3",
|
||||
"@formatjs/intl-localematcher": "^0.8.4",
|
||||
"@mdx-js/mdx": "^3.1.1",
|
||||
"@mixedbread/sdk": "^0.62.0",
|
||||
"@orama/core": "^1.2.19",
|
||||
"@oramacloud/client": "^2.1.4",
|
||||
"@shikijs/transformers": "^4.0.2",
|
||||
"@tanstack/react-router": "1.168.23",
|
||||
"@tanstack/react-router": "1.168.24",
|
||||
"@types/estree-jsx": "^1.0.5",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@types/js-yaml": "^4.0.9",
|
||||
@@ -138,7 +138,7 @@
|
||||
"algoliasearch": "5.51.0",
|
||||
"flexsearch": "^0.8.212",
|
||||
"image-size": "^2.0.2",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"negotiator": "^1.0.0",
|
||||
"next": "16.2.4",
|
||||
"npm-to-yarn": "^3.0.1",
|
||||
|
||||
@@ -115,12 +115,14 @@ export function createRehypeCode<
|
||||
}
|
||||
|
||||
const lazy = options.lazy ?? true;
|
||||
let preloadLangs: unknown[] | undefined = options.langs;
|
||||
if (!lazy) {
|
||||
preloadLangs ??= Object.keys(highlighter.getBundledLanguages());
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
highlighter.loadTheme(...(getRequiredThemes(options) as never[])),
|
||||
!lazy &&
|
||||
highlighter.loadLanguage(
|
||||
...((options.langs ?? Object.keys(highlighter.getBundledLanguages())) as never[]),
|
||||
),
|
||||
preloadLangs && highlighter.loadLanguage(...(preloadLangs as never[])),
|
||||
]);
|
||||
return rehypeShikiFromHighlighter(highlighter, {
|
||||
...options,
|
||||
|
||||
@@ -56,13 +56,13 @@
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"ajv": "^8.18.0",
|
||||
"ajv": "^8.20.0",
|
||||
"chokidar": "^5.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"github-slugger": "^2.0.0",
|
||||
"hast-util-to-jsx-runtime": "^2.3.6",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"remark": "^15.0.1",
|
||||
"remark-rehype": "^11.1.2",
|
||||
"shiki": "^4.0.2",
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"hono": "^4.12.15",
|
||||
"ignore": "^7.0.5",
|
||||
"katex": "0.16.45",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-server-dom-webpack": "^19.2.5",
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
"dependencies": {
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"js-yaml": "^4.1.1",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -141,7 +141,7 @@
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"motion": "^12.38.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react-remove-scroll": "^2.7.2",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@fumari/stf": "workspace:*",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"lucide-react": "^1.9.0",
|
||||
"lucide-react": "^1.11.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"ts-morph": "^28.0.0"
|
||||
},
|
||||
|
||||
Generated
+1026
-280
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user