Core: fix Shiki languages not loaded under lazy mode

This commit is contained in:
Fuma Nama
2026-04-25 22:49:02 +08:00
parent 9b5d2b34df
commit 61b15e99d3
43 changed files with 2839 additions and 325 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
fix Shiki languages not loaded under lazy mode
+41
View File
@@ -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
+267
View File
@@ -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;
}
}
+27
View File
@@ -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;
}
+30
View File
@@ -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;
}
}
+44
View File
@@ -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} />;
}
+32
View File
@@ -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}
/>
);
}
+21
View File
@@ -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} />;
}
+27
View File
@@ -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>
);
}
+15
View File
@@ -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} />;
}
+44
View File
@@ -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} />;
}
+63
View File
@@ -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>
);
}
+14
View File
@@ -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);
}
+7
View File
@@ -0,0 +1,7 @@
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;
+43
View File
@@ -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"
}
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
'@tailwindcss/postcss': {},
},
};
export default config;
+34
View File
@@ -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"]
}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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"
+3 -3
View File
@@ -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",
+4 -4
View File
@@ -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",
+3 -3
View File
@@ -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",
+3 -3
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+3 -3
View File
@@ -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,
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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"
},
+1026 -280
View File
File diff suppressed because it is too large Load Diff