Port everything from v2 except for things that require a server
This commit is contained in:
parent
cfff9199b6
commit
82e329023f
77 changed files with 8505 additions and 52 deletions
|
@ -6,11 +6,6 @@ Yes, another rewrite was needed. Again.
|
|||
- Yes all of it
|
||||
- Client
|
||||
- Overview
|
||||
- Search
|
||||
- Graph
|
||||
- Calendar
|
||||
- Todo
|
||||
- Collection pages
|
||||
- Note pages
|
||||
- Lexical
|
||||
- Excalidraw
|
||||
|
|
24
package.json
24
package.json
|
@ -9,25 +9,47 @@
|
|||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@excalidraw/excalidraw": "^0.18.0",
|
||||
"@lexical/react": "^0.28.0",
|
||||
"@lexical/utils": "^0.28.0",
|
||||
"@lexical/yjs": "^0.28.0",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@mdi/react": "^1.6.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.6",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.6",
|
||||
"@radix-ui/react-label": "^2.1.2",
|
||||
"@radix-ui/react-popover": "^1.1.6",
|
||||
"@radix-ui/react-radio-group": "^1.2.3",
|
||||
"@radix-ui/react-separator": "^1.1.2",
|
||||
"@radix-ui/react-slot": "^1.1.2",
|
||||
"@radix-ui/react-toggle": "^1.1.2",
|
||||
"@radix-ui/react-toggle-group": "^1.1.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.8",
|
||||
"@tailwindcss/vite": "^4.0.6",
|
||||
"@tanstack/react-router": "^1.114.3",
|
||||
"@tanstack/react-router-devtools": "^1.114.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "1.0.0",
|
||||
"lexical": "^0.28.0",
|
||||
"lucide-react": "^0.483.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"tailwind-merge": "^3.0.2",
|
||||
"tailwindcss": "^4.0.6",
|
||||
"tw-animate-css": "^1.2.4"
|
||||
"tw-animate-css": "^1.2.4",
|
||||
"y-excalidraw": "^2.0.12",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"y-websocket": "^2.1.0",
|
||||
"yjs": "^13.6.24",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@serwist/vite": "^9.0.12",
|
||||
"@serwist/window": "^9.0.12",
|
||||
"@tanstack/router-plugin": "^1.114.25",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.2.0",
|
||||
"@types/react": "^19.0.8",
|
||||
|
|
3059
pnpm-lock.yaml
generated
3059
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
253
src/components/app_sidebar.tsx
Normal file
253
src/components/app_sidebar.tsx
Normal file
|
@ -0,0 +1,253 @@
|
|||
import Icon from "@mdi/react";
|
||||
import { BookPlus, Calendar, ChevronsUpDown, Home, Inbox, ListTodo, LogOut, Monitor, Moon, Palette, PanelLeft, Plus, Search, Sun, Waypoints } from "lucide-react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarMenu, SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, useSidebar } from "~/components/ui/sidebar";
|
||||
import { type ColorName, colors } from "~/lib/color";
|
||||
import { type IconName, icons } from "~/lib/icon";
|
||||
import { NewNoteDialog } from "~/components/note/new_note_dialog";
|
||||
import { NewCollectionDialog } from "~/components/collection/new_collection_dialog";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { useCollectionNotesMetadata, useCollections } from "~/hooks/use-metadata";
|
||||
import { useIsMobile } from "~/hooks/use-mobile";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "~/components/ui/dropdown-menu";
|
||||
import { UserAvatar } from "~/components/user/user_avatar";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { useTheme } from "~/hooks/use-theme";
|
||||
import { ToggleGroup, ToggleGroupItem } from "./ui/toggle-group";
|
||||
|
||||
export function AppSidebar() {
|
||||
const collections = useCollections();
|
||||
|
||||
return (
|
||||
<Sidebar variant="inset" collapsible="icon">
|
||||
<SidebarContent>
|
||||
<HeaderButtons>
|
||||
<NewNoteDialog>
|
||||
<Button variant="ghost"><Plus /></Button>
|
||||
</NewNoteDialog>
|
||||
<NewCollectionDialog>
|
||||
<Button variant="ghost"><BookPlus /></Button>
|
||||
</NewCollectionDialog>
|
||||
</HeaderButtons>
|
||||
<SidebarGroup>
|
||||
<SidebarMenu>
|
||||
<NavButton href="/app/" label="Overview" icon={<Home />} />
|
||||
<NavButton href="/app/search" label="Search" icon={<Search />} />
|
||||
<InboxButton />
|
||||
<NavButton href="/app/graph" label="Graph" icon={<Waypoints />} />
|
||||
<NavButton href="/app/calendar" label="Calendar" icon={<Calendar />} />
|
||||
<NavButton href="/app/todo" label="Todo" icon={<ListTodo />} />
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Collections</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{collections.map((collection) => (<CollectionButton
|
||||
key={collection.get("id")}
|
||||
id={collection.get("id")}
|
||||
name={collection.get("name")}
|
||||
icon={collection.get("icon")}
|
||||
color={collection.get("color")}
|
||||
/>))}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
<SidebarToggle />
|
||||
<NavUser />
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
|
||||
type NavButtonProps = {
|
||||
href: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function NavButton(props: NavButtonProps) {
|
||||
const { setOpenMobile } = useSidebar();
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link to={props.href} onClick={() => setOpenMobile(false)}>
|
||||
{props.icon}
|
||||
<span>{props.label}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{props.children}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderButtons(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex flex-row justify-between",
|
||||
"transition-[marign,opa] duration-200 ease-linear mt-0",
|
||||
"group-data-[collapsible=icon]:opacity-0 group-data-[collapsible=icon]:-mt-9")}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CollectionButtonProps = {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: IconName | "";
|
||||
color: ColorName | "";
|
||||
}
|
||||
|
||||
function CollectionButton(props: CollectionButtonProps) {
|
||||
const icon = props.icon && icons[props.icon];
|
||||
const color = props.color ? colors[props.color] : colors.white;
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Link
|
||||
to="/app/collection/$id"
|
||||
params={{ id: props.id }}
|
||||
className="w-full flex items-center gap-2 group/item"
|
||||
>
|
||||
<div className="flex items-center justify-center h-4 w-4">
|
||||
{icon && <Icon path={icon.path} size={0.75} color={color.base} />}
|
||||
</div>
|
||||
<span
|
||||
className="group-hover/item:text-[var(--hover-color)]"
|
||||
style={{ "--hover-color": color.hover, }}
|
||||
>
|
||||
{props.name}
|
||||
</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarToggle() {
|
||||
const isMobile = useIsMobile();
|
||||
const { toggleSidebar } = useSidebar()
|
||||
|
||||
if (isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton asChild>
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={toggleSidebar}
|
||||
className="justify-start"
|
||||
>
|
||||
<PanelLeft />
|
||||
<span>Toggle Sidebar</span>
|
||||
</Button>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function InboxButton() {
|
||||
const notes = useCollectionNotesMetadata("");
|
||||
const count = notes.length;
|
||||
|
||||
return (
|
||||
<NavButton href="/app/inbox" label="Inbox" icon={<Inbox />}>
|
||||
<SidebarMenuBadge>{count}</SidebarMenuBadge>
|
||||
</NavButton>
|
||||
);
|
||||
}
|
||||
|
||||
function NavUser() {
|
||||
const isMobile = useIsMobile();
|
||||
const [theme, setTheme] = useTheme();
|
||||
// const { data: session } = useSession();
|
||||
// const user = session?.user;
|
||||
const user = {
|
||||
name: "Kalle Struik",
|
||||
email: "kalle@kallestruik.nl"
|
||||
};
|
||||
|
||||
const email = user?.email ?? undefined;
|
||||
const name = user?.name ?? undefined
|
||||
|
||||
function handleSignOut() {
|
||||
// // End the session
|
||||
// signOut();
|
||||
// // Clear all locally stored data
|
||||
// localStorage.clear();
|
||||
// indexedDB.databases().then((dbs) => {
|
||||
// dbs.forEach((db) => {
|
||||
// db.name && indexedDB.deleteDatabase(db.name);
|
||||
// });
|
||||
// });
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
||||
>
|
||||
<UserAvatar user={user} className="h-8 w-8 rounded-lg" />
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs">{email}</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="ml-auto size-4" />
|
||||
</SidebarMenuButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
|
||||
side={isMobile ? "top" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<UserAvatar user={user} className="h-8 w-8 rounded-lg" />
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs">{email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel className="py-0">
|
||||
<div className="flex flex-row place-content-between w-full items-center">
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<Palette className="size-4 text-muted-foreground" />
|
||||
Theme
|
||||
</div>
|
||||
<ToggleGroup type="single" size="sm" defaultValue={theme}>
|
||||
<ToggleGroupItem value="system" onClick={() => setTheme("system")}><Monitor /></ToggleGroupItem>
|
||||
<ToggleGroupItem value="light" onClick={() => setTheme("light")}><Sun /></ToggleGroupItem>
|
||||
<ToggleGroupItem value="dark" onClick={() => setTheme("dark")}><Moon /></ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOut />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
);
|
||||
}
|
46
src/components/collection/collection_header.tsx
Normal file
46
src/components/collection/collection_header.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { mdiCog } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import { colors } from "~/lib/color";
|
||||
import { icons } from "~/lib/icon";
|
||||
import { CollectionSettingsDialog } from "~/components/collection/collection_settings_dialog";
|
||||
import { Skeleton } from "~/components/ui/skeleton";
|
||||
import { useCollection } from "~/hooks/use-metadata";
|
||||
import type { CollectionId } from "~/lib/metadata";
|
||||
|
||||
type CollectionHeaderProps = {
|
||||
collectionId: CollectionId;
|
||||
};
|
||||
|
||||
export function CollectionHeader(props: CollectionHeaderProps) {
|
||||
const collection = useCollection(props.collectionId);
|
||||
|
||||
const color = collection ? colors[collection.get("color")] : undefined;
|
||||
const icon = collection ? icons[collection.get("icon")] : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full bg-sidebar flex flex-row items-center">
|
||||
<div className="p-2 flex justify-center items-center">
|
||||
{collection
|
||||
? (icon && <Icon path={icon.path} size={1.5} color={color!.base} />)
|
||||
: <Skeleton className="w-9 h-9" />
|
||||
}
|
||||
</div>
|
||||
{collection
|
||||
? <div className="p-2 flex-grow font-bold text-xl">{collection.get("name")}</div>
|
||||
: <div className="p-2 flex-grow"><Skeleton className="h-7 max-w-full w-[200px]" /></div>
|
||||
}
|
||||
<CollectionSettingsDialog
|
||||
name={collection?.get("name")}
|
||||
collectionId={props.collectionId}
|
||||
>
|
||||
<button
|
||||
className="p-4 flex justify-center items-center hover:bg-sidebar-accent"
|
||||
>
|
||||
<Icon path={mdiCog} size={1} />
|
||||
</button>
|
||||
</CollectionSettingsDialog>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
211
src/components/collection/collection_settings_dialog.tsx
Normal file
211
src/components/collection/collection_settings_dialog.tsx
Normal file
|
@ -0,0 +1,211 @@
|
|||
import * as Y from "yjs";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { ColorPicker } from "../form/color_picker";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { IconPicker } from "../form/icon_picker";
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "~/components/ui/alert-dialog";
|
||||
import { useCollection, useCollections } from "~/hooks/use-metadata";
|
||||
import { type CollectionId, type CollectionProperty, deleteCollection } from "~/lib/metadata";
|
||||
import Icon from "@mdi/react";
|
||||
import { mdiPin, mdiPinOff, mdiPlus, mdiTrashCan } from "@mdi/js";
|
||||
import { createCollectionProperty, deleteCollectionProperty } from "~/lib/property";
|
||||
import { useEffect } from "react";
|
||||
import { PropertyTypeCombobox } from "../form/property_type_combobox";
|
||||
|
||||
type CollectionSettingsDialogProps = {
|
||||
name?: string;
|
||||
collectionId: CollectionId;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
export function CollectionSettingsDialog(props: CollectionSettingsDialogProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{props.children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{props.name} settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Settings for the {props.name} collection.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="h-[600px] overflow-scroll flex flex-col gap-4">
|
||||
<SettingsSection collectionId={props.collectionId} />
|
||||
<PropertiesSection collectionId={props.collectionId} />
|
||||
<DangerSection collectionId={props.collectionId} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsSection(props: { collectionId: CollectionId }) {
|
||||
const collection = useCollection(props.collectionId)
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<div className="flex flex-row gap-2">
|
||||
<IconPicker
|
||||
initialValue={collection.get("icon")}
|
||||
onChange={(icon) => collection.set("icon", icon ?? "")}
|
||||
/>
|
||||
<div className="grid flex-grow items-center gap-1.5">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
className="flex-grow"
|
||||
defaultValue={collection.get("name")}
|
||||
placeholder="Name..."
|
||||
onChange={(name) => collection.set("name", name.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ColorPicker
|
||||
initialValue={collection.get("color")}
|
||||
onChange={(color) => collection.set("color", color)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertiesSection(props: { collectionId: CollectionId }) {
|
||||
const collection = useCollection(props.collectionId)
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure that the properties array exists on the collection
|
||||
useEffect(() => {
|
||||
if (!collection.has("properties")) {
|
||||
collection.set("properties", new Y.Array() as any)
|
||||
}
|
||||
}, [collection])
|
||||
|
||||
const properties = collection.get("properties");
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header label="Properties">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => properties && createCollectionProperty(properties)}
|
||||
>
|
||||
<Icon path={mdiPlus} size={.75} />
|
||||
</Button>
|
||||
</Header>
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{properties?.map((property) =>
|
||||
<PropertyItem key={property.get("id")}
|
||||
property={property}
|
||||
onDelete={() => deleteCollectionProperty(properties, property.get("id"))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type PropertyItemProps = {
|
||||
property: CollectionProperty,
|
||||
onDelete: () => void,
|
||||
}
|
||||
function PropertyItem(props: PropertyItemProps) {
|
||||
const { property: prop } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-2">
|
||||
<Input
|
||||
className="flex-grow"
|
||||
defaultValue={prop.get("name")}
|
||||
placeholder="Name..."
|
||||
onChange={(event) => prop.set("name", event.target.value)}
|
||||
/>
|
||||
<PropertyTypeCombobox
|
||||
initialValue={prop.get("type")}
|
||||
onSelect={(type) => prop.set("type", type)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => prop.set("pinned", !prop.get("pinned"))}
|
||||
>
|
||||
<Icon path={prop.get("pinned") ? mdiPinOff : mdiPin} size={.75} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={props.onDelete}
|
||||
>
|
||||
<Icon path={mdiTrashCan} size={.75} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DangerSection(props: { collectionId: CollectionId }) {
|
||||
const collections = useCollections();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Header label="Danger zone">
|
||||
</Header>
|
||||
<div className="flex flex-row gap-2 p-2 items-center">
|
||||
<div className="flex-grow flex flex-col gap-1">
|
||||
<span className="text-sm font-bold">Delete collection</span>
|
||||
<span className="text-xs text-zinc-400">Delete the collection and all items contained within. WARNING: This action is irreversible!</span>
|
||||
</div>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This action cannot be undone. This will permanently delete the collection and ALL items contained in it.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction asChild>
|
||||
<Button
|
||||
variant="destructive"
|
||||
// TODO: Navigate away from the page when clicked
|
||||
onClick={() => deleteCollection(collections, props.collectionId)}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HeaderProps = {
|
||||
label: string,
|
||||
children?: React.ReactNode,
|
||||
}
|
||||
|
||||
function Header(props: HeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-row gap-2 pr-2 items-center">
|
||||
<h1 className="font-bold flex-grow">{props.label}</h1>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
76
src/components/collection/new_collection_dialog.tsx
Normal file
76
src/components/collection/new_collection_dialog.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { useState } from "react";
|
||||
import { type ColorName } from "~/lib/color";
|
||||
import { type IconName } from "~/lib/icon";
|
||||
import { ColorPicker } from "../form/color_picker";
|
||||
import { IconPicker } from "../form/icon_picker";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { createCollection } from "~/lib/metadata";
|
||||
import { useCollections } from "~/hooks/use-metadata";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
type NewCollectionDialogProps = {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function NewCollectionDialog(props: NewCollectionDialogProps) {
|
||||
const collections = useCollections();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [icon, setIcon] = useState<IconName>();
|
||||
const [color, setColor] = useState<ColorName>("white");
|
||||
|
||||
const navigate = useNavigate();
|
||||
function submit() {
|
||||
const newCollection = createCollection(collections, {
|
||||
name,
|
||||
icon: icon ?? "",
|
||||
color,
|
||||
})
|
||||
setName("");
|
||||
setIcon(undefined);
|
||||
setColor("white");
|
||||
|
||||
navigate({ to: "/app/collection/$id", params: { id: newCollection.get("id") } });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{props.children}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New collection</DialogTitle>
|
||||
<DialogDescription>
|
||||
Settings for the new collection
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<div className="flex flex-row gap-2">
|
||||
<IconPicker onChange={setIcon} />
|
||||
<div className="grid flex-grow items-center gap-1.5">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
className="flex-grow"
|
||||
placeholder="Name..."
|
||||
onChange={(name) => setName(name.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ColorPicker onChange={setColor} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
onClick={submit}
|
||||
>Create</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
17
src/components/editor/link_icon.tsx
Normal file
17
src/components/editor/link_icon.tsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { mdiOpenInNew } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function LinkIcon(props: { url: string }) {
|
||||
return (
|
||||
<a className={cn(
|
||||
"link-icon",
|
||||
"inline-flex align-text-bottom items-center justify-center ml-0.5",
|
||||
)}
|
||||
href={props.url}
|
||||
target="_blank"
|
||||
>
|
||||
<Icon path={mdiOpenInNew} size={.75} />
|
||||
</a>
|
||||
);
|
||||
}
|
13
src/components/editor/task_icon.tsx
Normal file
13
src/components/editor/task_icon.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import Icon from "@mdi/react";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function TaskIcon(props: { icon?: string }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
"task-icon",
|
||||
"rounded border border-white align-text-bottom mr-2 select-none w-5 h-5 inline-flex justify-center items-center"
|
||||
)}>
|
||||
{props.icon && <Icon path={props.icon} size={1} />}
|
||||
</div>
|
||||
);
|
||||
}
|
18
src/components/editor/term_icon.tsx
Normal file
18
src/components/editor/term_icon.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { mdiHelp } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
export function TermIcon(props: { term: string }) {
|
||||
return (
|
||||
<Link className={cn(
|
||||
"term-icon",
|
||||
"align-text-bottom select-none inline-flex justify-center items-center"
|
||||
)}
|
||||
to="/app/term/$term"
|
||||
params={{ term: props.term }}
|
||||
>
|
||||
<Icon path={mdiHelp} size={.75} />
|
||||
</Link>
|
||||
);
|
||||
}
|
85
src/components/form/collection_picker.tsx
Normal file
85
src/components/form/collection_picker.tsx
Normal file
|
@ -0,0 +1,85 @@
|
|||
import Icon from "@mdi/react";
|
||||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||
import { useCollection, useCollections } from "~/hooks/use-metadata";
|
||||
import { colors } from "~/lib/color";
|
||||
import { icons } from "~/lib/icon";
|
||||
import { type CollectionId } from "~/lib/metadata";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type CollectionPickerProps = {
|
||||
onChange?: (collectionId?: CollectionId) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CollectionPicker(props: CollectionPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState<CollectionId | undefined>();
|
||||
|
||||
const collections = useCollections();
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn("w-[200px] justify-between", props.className)}
|
||||
>
|
||||
<Entry collectionId={value} />
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search collections..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No collection found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="none"
|
||||
onSelect={() => {
|
||||
setValue(undefined);
|
||||
setOpen(false);
|
||||
props.onChange && props.onChange(undefined);
|
||||
}}
|
||||
>
|
||||
<Entry collectionId={undefined} />
|
||||
</CommandItem>
|
||||
{collections.map((collection) => (
|
||||
<CommandItem
|
||||
key={collection.get("id")}
|
||||
value={collection.get("name")}
|
||||
onSelect={() => {
|
||||
setValue(collection.get("id"));
|
||||
setOpen(false);
|
||||
props.onChange && props.onChange(collection.get("id"));
|
||||
}}
|
||||
>
|
||||
<Entry collectionId={collection.get("id")} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function Entry(props: { collectionId?: CollectionId }) {
|
||||
const collection = useCollection(props.collectionId)
|
||||
|
||||
return (
|
||||
<div className="flex flex-row w-full gap-3 items-center">
|
||||
<div className="h-4 w-4 flex items-center justify-center">
|
||||
{collection?.get("icon") && <Icon color={colors[collection.get("color")].base} path={icons[collection.get("icon")]!.path} size={1} />}
|
||||
</div>
|
||||
<span style={{ color: collection ? colors[collection.get("color")].hover : undefined }}>{collection?.get("name") ?? "None"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
84
src/components/form/color_picker.tsx
Normal file
84
src/components/form/color_picker.tsx
Normal file
|
@ -0,0 +1,84 @@
|
|||
import { ChevronsUpDown } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||
import { type Color, type ColorName, colors } from "~/lib/color";
|
||||
import { cn } from "~/lib/utils";
|
||||
|
||||
type ColorPickerProps = ({
|
||||
onChange?: (color: ColorName) => void;
|
||||
withNone?: false;
|
||||
} | {
|
||||
onChange?: (color?: ColorName) => void;
|
||||
withNone: true;
|
||||
}) & {
|
||||
initialValue?: ColorName;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ColorPicker(props: ColorPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [value, setValue] = useState<ColorName | undefined>(props.initialValue ?? (props.withNone ? undefined : "white"));
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn("w-[200px] justify-between", props.className)}
|
||||
>
|
||||
<Entry color={value ? colors[value] : undefined} />
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search colors..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No color found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{props.withNone && <CommandItem
|
||||
value={"none"}
|
||||
onSelect={() => {
|
||||
setValue(undefined)
|
||||
setOpen(false)
|
||||
props.onChange && props.onChange(undefined)
|
||||
}}
|
||||
>
|
||||
<Entry color={undefined} />
|
||||
</CommandItem>}
|
||||
{Object.entries(colors).map(([key, value]) => (
|
||||
<CommandItem
|
||||
key={key}
|
||||
value={key}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue as ColorName)
|
||||
setOpen(false)
|
||||
props.onChange && props.onChange(currentValue as ColorName)
|
||||
}}
|
||||
>
|
||||
<Entry color={value} />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function Entry(props: { color?: Color }) {
|
||||
return (
|
||||
<div className="flex flex-row w-full gap-2 items-center">
|
||||
<div
|
||||
className="h-4 w-4"
|
||||
style={{ backgroundColor: props.color?.base }}
|
||||
/>
|
||||
<span style={{ color: props.color?.hover }}>{props.color?.label ?? "None"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
82
src/components/form/icon_picker.tsx
Normal file
82
src/components/form/icon_picker.tsx
Normal file
|
@ -0,0 +1,82 @@
|
|||
import Icon from "@mdi/react";
|
||||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
|
||||
import { type Icon as IconType, type IconName, icons } from "~/lib/icon";
|
||||
|
||||
type IconPickerProps = ({
|
||||
onChange?: (icon: IconName) => void;
|
||||
withNone?: false;
|
||||
} | {
|
||||
onChange?: (icon?: IconName) => void;
|
||||
withNone: true;
|
||||
}) & {
|
||||
initialValue?: IconName;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// TODO: Make this faster by not showing all icons at once
|
||||
export function IconPicker(props: IconPickerProps) {
|
||||
const [value, setValue] = useState<IconName | undefined>(props.initialValue);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="h-full aspect-square"
|
||||
>
|
||||
{value && <Entry icon={icons[value] as IconType} />}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search types..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No icon found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{props.withNone && <CommandItem
|
||||
value={"none"}
|
||||
onSelect={() => {
|
||||
setValue(undefined)
|
||||
setOpen(false)
|
||||
props.onChange && props.onChange(undefined)
|
||||
}}>
|
||||
<Entry withLabel />
|
||||
</CommandItem>}
|
||||
{Object.entries(icons).map(([key, value]) => (
|
||||
<CommandItem
|
||||
key={key}
|
||||
value={key}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue as IconName)
|
||||
setOpen(false)
|
||||
props.onChange && props.onChange(currentValue as IconName)
|
||||
}}
|
||||
>
|
||||
<Entry icon={value} withLabel />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function Entry(props: { icon?: IconType, withLabel?: boolean }) {
|
||||
const withLabel = props.withLabel ?? false;
|
||||
return (
|
||||
<div className="flex flex-row w-full gap-2 items-center">
|
||||
<div className="h-6 w-6">
|
||||
{props.icon && <Icon path={props.icon.path} size={1} />}
|
||||
</div>
|
||||
{withLabel && <span>{props.icon?.name ?? "None"}</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
57
src/components/form/property_type_combobox.tsx
Normal file
57
src/components/form/property_type_combobox.tsx
Normal file
|
@ -0,0 +1,57 @@
|
|||
import { useState } from "react"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover"
|
||||
import { ChevronsUpDown } from "lucide-react"
|
||||
import { properties, type PropertyType } from "~/lib/property"
|
||||
import { Button } from "~/components/ui/button"
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command"
|
||||
|
||||
type PropertyTypeComboboxProps = {
|
||||
initialValue: PropertyType
|
||||
onSelect: (value: PropertyType) => void
|
||||
}
|
||||
|
||||
export function PropertyTypeCombobox(props: PropertyTypeComboboxProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [value, setValue] = useState(props.initialValue)
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="w-[200px] justify-between"
|
||||
>
|
||||
{value in properties
|
||||
? properties[value as PropertyType]
|
||||
: "Select type..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[200px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search types..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No type found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{Object.entries(properties).map(([key, value]) => (
|
||||
<CommandItem
|
||||
key={key}
|
||||
value={key}
|
||||
onSelect={(currentValue) => {
|
||||
setValue(currentValue as PropertyType)
|
||||
setOpen(false)
|
||||
props.onSelect(currentValue as PropertyType)
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
120
src/components/note/new_note_dialog.tsx
Normal file
120
src/components/note/new_note_dialog.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
import { useState } from "react";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { ColorPicker } from "~/components/form/color_picker";
|
||||
import type { ColorName } from "~/lib/color";
|
||||
import { IconPicker } from "~/components/form/icon_picker";
|
||||
import type { IconName } from "~/lib/icon";
|
||||
import { CollectionPicker } from "~/components/form/collection_picker";
|
||||
import { type CollectionId, createNote } from "~/lib/metadata";
|
||||
import { useNotesMetadata } from "~/hooks/use-metadata";
|
||||
import { RadioGroup, RadioGroupItem } from "~/components/ui/radio-group";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
type NewNoteDialogProps = {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function NewNoteDialog(props: NewNoteDialogProps) {
|
||||
const noteMetadata = useNotesMetadata();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [icon, setIcon] = useState<IconName>();
|
||||
const [collection, setCollection] = useState<CollectionId | undefined>();
|
||||
const [primaryColor, setPrimaryColor] = useState<ColorName | undefined>();
|
||||
const [secondaryColor, setSecondaryColor] = useState<ColorName | undefined>();
|
||||
const [type, setType] = useState<"text" | "canvas">("text");
|
||||
|
||||
const navigate = useNavigate();
|
||||
function submit() {
|
||||
const newNote = createNote(noteMetadata, {
|
||||
name,
|
||||
icon,
|
||||
collectionId: collection,
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
type,
|
||||
});
|
||||
|
||||
setName("");
|
||||
setIcon(undefined);
|
||||
setCollection(undefined);
|
||||
setPrimaryColor(undefined);
|
||||
setSecondaryColor(undefined);
|
||||
setType("text");
|
||||
|
||||
navigate({ to: "/app/note/$id", params: { id: newNote.get("id") } });
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{props.children}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New note</DialogTitle>
|
||||
<DialogDescription>
|
||||
Settings for the new collection
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
<div className="flex flex-row gap-2">
|
||||
<IconPicker onChange={setIcon} />
|
||||
<div className="grid flex-grow items-center gap-1.5">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
className="flex-grow"
|
||||
placeholder="Name..."
|
||||
onChange={(name) => setName(name.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid flex-grow items-center gap-1.5">
|
||||
<Label>Collection</Label>
|
||||
<CollectionPicker onChange={setCollection} className="w-full flex-grow" />
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="grid flex-grow items-center gap-1.5">
|
||||
<Label>Primary color</Label>
|
||||
<ColorPicker withNone onChange={setPrimaryColor} className="w-full flex-grow" />
|
||||
</div>
|
||||
<div className="grid flex-grow items-center gap-1.5">
|
||||
<Label>Secondary color</Label>
|
||||
<ColorPicker withNone onChange={setSecondaryColor} className="w-full flex-grow" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid flex-grow items-center gap-1.5">
|
||||
<Label>Type</Label>
|
||||
<RadioGroup
|
||||
onValueChange={setType as any}
|
||||
defaultValue={type}
|
||||
className="flex flex-row space-y-1 items-center"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="text" id="type-text" />
|
||||
<Label htmlFor="type-text">Text</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="canvas" id="type-canvas" />
|
||||
<Label htmlFor="type-canvas">Canvas</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
onClick={() => submit()}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
76
src/components/note/note_canvas.tsx
Normal file
76
src/components/note/note_canvas.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import type { ExcalidrawImperativeAPI } from "@excalidraw/excalidraw/types";
|
||||
import { ExcalidrawBinding, yjsToExcalidraw } from "y-excalidraw"
|
||||
import { Button, Excalidraw } from "@excalidraw/excalidraw";
|
||||
import * as Y from "yjs";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import "@excalidraw/excalidraw/index.css";
|
||||
import { useNoteMetadata } from "~/hooks/use-metadata";
|
||||
import { Settings } from "lucide-react";
|
||||
import { NoteSettingsDialog } from "./note_settings_dialog";
|
||||
import { useNoteArray, useNoteAwareness, useNoteMap } from "~/hooks/use-note";
|
||||
import { useEffectiveTheme } from "~/hooks/use-theme";
|
||||
|
||||
export default function NoteCanvas(props: { noteId: string }) {
|
||||
const [api, setApi] = useState<ExcalidrawImperativeAPI | null>(null);
|
||||
const [binding, setBindings] = useState<ExcalidrawBinding | null>(null);
|
||||
const excalidrawRef = useRef(null);
|
||||
const metadata = useNoteMetadata(props.noteId);
|
||||
const theme = useEffectiveTheme();
|
||||
|
||||
const yElements = useNoteArray("elements") as Y.Array<any>;
|
||||
const yAssets = useNoteMap("assets");
|
||||
const awareness = useNoteAwareness();
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
|
||||
const binding = new ExcalidrawBinding(
|
||||
yElements,
|
||||
yAssets,
|
||||
api,
|
||||
awareness,
|
||||
// excalidraw dom is needed to override the undo/redo buttons in the UI as there is no way to override it via props in excalidraw
|
||||
// You might need to pass {trackedOrigins: new Set()} to undomanager depending on whether your provider sets an origin or not
|
||||
{ excalidrawDom: excalidrawRef.current!, undoManager: new Y.UndoManager(yElements) },
|
||||
);
|
||||
setBindings(binding);
|
||||
return () => {
|
||||
setBindings(null);
|
||||
binding.destroy();
|
||||
};
|
||||
}, [api]);
|
||||
|
||||
const initData = {
|
||||
elements: yjsToExcalidraw(yElements)
|
||||
}
|
||||
|
||||
const excalidraw = useMemo(() => <Excalidraw
|
||||
initialData={initData} // Need to set the initial data
|
||||
excalidrawAPI={setApi}
|
||||
onPointerUpdate={binding?.onPointerUpdate}
|
||||
theme={theme}
|
||||
UIOptions={{
|
||||
canvasActions: {
|
||||
toggleTheme: false,
|
||||
},
|
||||
}}
|
||||
renderTopRightUI={() => (<>
|
||||
{metadata && <NoteSettingsDialog noteMetadata={metadata}>
|
||||
<Button
|
||||
style={{ width: "2.25rem", height: "2.25rem" }}
|
||||
onSelect={() => { }}
|
||||
>
|
||||
<Settings />
|
||||
</Button>
|
||||
</NoteSettingsDialog>}
|
||||
</>)}
|
||||
/>, []);
|
||||
return (
|
||||
<div className="w-full h-full" ref={excalidrawRef}>
|
||||
{excalidraw}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
96
src/components/note/note_header.tsx
Normal file
96
src/components/note/note_header.tsx
Normal file
|
@ -0,0 +1,96 @@
|
|||
import Icon from "@mdi/react";
|
||||
import { colors } from "~/lib/color";
|
||||
import { icons } from "~/lib/icon";
|
||||
import { NotePropertiesDialog } from "~/components/note/note_properties_dialog";
|
||||
import { mdiCircle, mdiCog } from "@mdi/js";
|
||||
import { NoteSettingsDialog } from "~/components/note/note_settings_dialog";
|
||||
import { useCollection, useNoteMetadata } from "~/hooks/use-metadata";
|
||||
import type { NoteId, NoteMetadata } from "~/lib/metadata";
|
||||
|
||||
export function NoteHeader(props: { id: NoteId }) {
|
||||
const noteMetadata = useNoteMetadata(props.id);
|
||||
|
||||
if (!noteMetadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const primaryColorName = noteMetadata.get("primaryColor");
|
||||
const secondaryColorName = noteMetadata.get("secondaryColor");
|
||||
const primaryColor = primaryColorName ? colors[primaryColorName] : colors.white;
|
||||
const secondaryColor = secondaryColorName ? colors[secondaryColorName] : primaryColor;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex-shrink-0 w-full h-[300px] relative shadow mb-[42px]" style={{
|
||||
background: `linear-gradient(135deg, ${primaryColor.base}, ${secondaryColor.base})`
|
||||
}}>
|
||||
<div className="flex flex-row justify-end">
|
||||
<NoteSettingsDialog noteMetadata={noteMetadata}>
|
||||
<button
|
||||
className="m-2 p-2 flex justify-center items-center bg-secondary bg-opacity-50 hover:bg-opacity-90 rounded"
|
||||
>
|
||||
<Icon path={mdiCog} size={1} />
|
||||
</button>
|
||||
</NoteSettingsDialog>
|
||||
</div>
|
||||
<div className="absolute left-8 bottom-[-42px] bg-sidebar rounded-xl flex items-center justify-center h-32 w-32 border-4 border-background">
|
||||
{noteMetadata.get("icon") && <Icon path={icons[noteMetadata.get("icon")]!.path} color={primaryColor.base} size={4} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-8 flex flex-col gap-1">
|
||||
<div className="flex flex-row gap-1">
|
||||
{<input
|
||||
className="bg-transparent outline-none font-bold text-3xl flex-grow"
|
||||
defaultValue={noteMetadata.get("title")}
|
||||
onChange={(e) => noteMetadata.set("title", e.target.value)}
|
||||
/>}
|
||||
</div>
|
||||
<Properties noteMetadata={noteMetadata} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Properties(props: { noteMetadata: NoteMetadata }) {
|
||||
const { noteMetadata } = props;
|
||||
const collection = useCollection(noteMetadata.get("collectionId"));
|
||||
|
||||
if (!collection) {
|
||||
return <span className="text-muted">No properties</span>;
|
||||
}
|
||||
|
||||
const collectionProps = collection.get("properties")?.toArray();
|
||||
if (!collectionProps) {
|
||||
return <span className="text-muted">No properties</span>;
|
||||
}
|
||||
|
||||
const pinnedProperties = collectionProps.filter(property => property.get("pinned"));
|
||||
|
||||
return (
|
||||
<NotePropertiesDialog noteMetadata={noteMetadata}>
|
||||
<button className="flex flex-row w-fit rounded gap-2 py-1 px-2 hover:bg-accent items-center">
|
||||
{pinnedProperties.length === 0
|
||||
? <span className="text-zinc-400">No pinned properties</span>
|
||||
: pinnedProperties.flatMap(property => {
|
||||
const propertyId = property.get("id");
|
||||
const value = noteMetadata.get("properties")
|
||||
?.toArray()
|
||||
.find(it => it.get("propertyId") == propertyId)
|
||||
?.get("value");
|
||||
const seperator = <Icon key={`${propertyId}-sep`} className="text-muted text-2xl" path={mdiCircle} size={.25} />;
|
||||
|
||||
if (!value) {
|
||||
return [seperator, <span key={propertyId} className="text-muted">
|
||||
{property.get("name")}
|
||||
</span>]
|
||||
} else {
|
||||
return [seperator, <span key={propertyId} className="text-muted">
|
||||
{value}
|
||||
</span>]
|
||||
}
|
||||
}).slice(1)}
|
||||
</button>
|
||||
</NotePropertiesDialog>
|
||||
);
|
||||
}
|
114
src/components/note/note_properties_dialog.tsx
Normal file
114
src/components/note/note_properties_dialog.tsx
Normal file
|
@ -0,0 +1,114 @@
|
|||
import * as Y from "yjs";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import type { NoteMetadata, NoteProperty } from "~/lib/metadata";
|
||||
import { createNoteProperty, type PropertyType } from "~/lib/property";
|
||||
import { useCollection } from "~/hooks/use-metadata";
|
||||
|
||||
type NotePropertiesDialogProps = {
|
||||
noteMetadata: NoteMetadata;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
export function NotePropertiesDialog(props: NotePropertiesDialogProps) {
|
||||
const { noteMetadata } = props;
|
||||
|
||||
if (!noteMetadata) {
|
||||
return props.children;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{props.children}
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-[800px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{noteMetadata.get("title")} properties</DialogTitle>
|
||||
<DialogDescription>
|
||||
Properties for the {noteMetadata.get("title")} note
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="h-[600px] overflow-scroll flex flex-col gap-4">
|
||||
{noteMetadata.has("collectionId")
|
||||
? <PropertiesSection noteMetadata={noteMetadata} />
|
||||
: <span>Only notes that belong to a collection can have properties</span>
|
||||
}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PropertiesSection(props: { noteMetadata: NoteMetadata }) {
|
||||
const { noteMetadata } = props;
|
||||
const collection = useCollection(noteMetadata.get("collectionId"));
|
||||
|
||||
useEffect(() => {
|
||||
if (!noteMetadata.has("properties")) {
|
||||
noteMetadata.set("properties", new Y.Array() as any)
|
||||
}
|
||||
}, [noteMetadata])
|
||||
|
||||
const noteProperties = noteMetadata.get("properties");
|
||||
if (!noteProperties) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const collectionProps = collection.get("properties")?.toArray();
|
||||
if (!collectionProps) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{collectionProps.map((property) => {
|
||||
const propertyId = property.get("id");
|
||||
const noteProperty = noteProperties
|
||||
?.toArray()
|
||||
.find(it => it.get("propertyId") == propertyId)
|
||||
return <PropertyItem key={propertyId}
|
||||
noteProperty={noteProperty}
|
||||
name={property.get("name")}
|
||||
type={property.get("type")}
|
||||
create={() => createNoteProperty(noteProperties, { propertyId })}
|
||||
/>
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
type PropertyItemProps = {
|
||||
noteProperty?: NoteProperty;
|
||||
name: string;
|
||||
type: PropertyType;
|
||||
create: () => NoteProperty;
|
||||
}
|
||||
function PropertyItem(props: PropertyItemProps) {
|
||||
const handleChange = useCallback((value: string) => {
|
||||
if (props.noteProperty) {
|
||||
props.noteProperty.set("value", value);
|
||||
} else {
|
||||
props.create().set("value", value);
|
||||
}
|
||||
}, [props.noteProperty, props.create]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<span className="w-[200px]">{props.name}</span>
|
||||
<Input
|
||||
className="flex-grow"
|
||||
defaultValue={props.noteProperty?.get("value")}
|
||||
placeholder={`${props.name}...`}
|
||||
onChange={(value) => handleChange(value.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
75
src/components/note/note_settings_dialog.tsx
Normal file
75
src/components/note/note_settings_dialog.tsx
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "~/components/ui/dialog";
|
||||
import { IconPicker } from "../form/icon_picker";
|
||||
import { Input } from "~/components/ui/input";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { ColorPicker } from "../form/color_picker";
|
||||
import type { NoteMetadata } from "~/lib/metadata";
|
||||
|
||||
type NoteSettingsDialogProps = {
|
||||
noteMetadata: NoteMetadata;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export function NoteSettingsDialog(props: NoteSettingsDialogProps) {
|
||||
const { noteMetadata } = props;
|
||||
if (!noteMetadata) {
|
||||
return props.children;
|
||||
}
|
||||
|
||||
const primaryColor = noteMetadata.get("primaryColor");
|
||||
const secondaryColor = noteMetadata.get("secondaryColor");
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
{props.children}
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{noteMetadata.get("title")} settings</DialogTitle>
|
||||
<DialogDescription>
|
||||
Settings for {noteMetadata.get("title")}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{<div className="flex flex-col gap-2 p-2">
|
||||
<div className="flex flex-row gap-2">
|
||||
<IconPicker withNone
|
||||
onChange={(newIcon) => noteMetadata.set("icon", newIcon ?? "")}
|
||||
initialValue={noteMetadata.get("icon") == "" ? undefined : noteMetadata.get("icon")}
|
||||
/>
|
||||
<div className="grid flex-grow items-center gap-1.5">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
className="flex-grow"
|
||||
placeholder="Name..."
|
||||
defaultValue={noteMetadata.get("title")}
|
||||
onChange={(name) => noteMetadata.set("title", name.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row gap-2">
|
||||
<div className="grid flex-grow items-center gap-1.5">
|
||||
<Label>Primary color</Label>
|
||||
<ColorPicker
|
||||
withNone
|
||||
onChange={(color) => noteMetadata.set("primaryColor", color ?? "")}
|
||||
initialValue={primaryColor == "" ? undefined : primaryColor}
|
||||
className="w-full flex-grow"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid flex-grow items-center gap-1.5">
|
||||
<Label>Secondary color</Label>
|
||||
<ColorPicker
|
||||
withNone
|
||||
onChange={(color) => noteMetadata.set("secondaryColor", color ?? "")}
|
||||
initialValue={secondaryColor == "" ? undefined : secondaryColor}
|
||||
className="w-full flex-grow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
105
src/components/note/notes_grid.tsx
Normal file
105
src/components/note/notes_grid.tsx
Normal file
|
@ -0,0 +1,105 @@
|
|||
import { mdiMagnify } from "@mdi/js";
|
||||
import Icon from "@mdi/react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { Command } from "cmdk";
|
||||
import { colors } from "~/lib/color";
|
||||
import { icons } from "~/lib/icon";
|
||||
import type { NoteMetadata } from "~/lib/metadata";
|
||||
|
||||
type NotesGridProps = {
|
||||
notes?: NoteMetadata[];
|
||||
allUnpinned?: boolean;
|
||||
};
|
||||
export function NotesGrid(props: NotesGridProps) {
|
||||
const allUnpinned = props.allUnpinned ?? false;
|
||||
|
||||
const pinnedNotes = props.notes?.filter(it => !allUnpinned && it.get("pinned"))?.map((note) => <DetailedNote key={note.get("id")} note={note} />);
|
||||
const normalNotes = props.notes?.filter(it => allUnpinned || !it.get("pinned"))?.map((note) => <NormalNote key={note.get("id")} note={note} />);
|
||||
|
||||
return (
|
||||
<Command className="p-4 flex flex-col gap-4 items-stretch">
|
||||
<div className="bg-card rounded shadow flex flex-row items-center w-full max-w-[768px] mx-auto">
|
||||
<div className="p-2 flex justify-center items-center">
|
||||
<Icon path={mdiMagnify} size={1.5} />
|
||||
</div>
|
||||
<Command.Input asChild>
|
||||
<input type="text" placeholder="Search..." className="flex-grow bg-transparent py-4 px-2 outline-none" />
|
||||
</Command.Input>
|
||||
|
||||
</div>
|
||||
<Command.List className="[&>div]:flex [&>div]:flex-col [&>div]:gap-4">
|
||||
<Command.Empty>No notes found</Command.Empty>
|
||||
<Command.Group
|
||||
value="pinned"
|
||||
className="[&>div]:grid [&>div]:w-full [&>div]:gap-4 [&>div]:grid-cols-[repeat(auto-fill,minmax(500px,1fr))]"
|
||||
>
|
||||
{pinnedNotes}
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group
|
||||
value="unpinned"
|
||||
className="[&>div]:grid [&>div]:w-full [&>div]:gap-4 [&>div]:grid-cols-[repeat(auto-fill,minmax(300px,1fr))]"
|
||||
>
|
||||
{normalNotes}
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
</Command>
|
||||
);
|
||||
}
|
||||
|
||||
type DetailedNoteProps = {
|
||||
note: NoteMetadata;
|
||||
}
|
||||
|
||||
function DetailedNote(props: DetailedNoteProps) {
|
||||
const { note } = props;
|
||||
|
||||
const primaryColorName = note.get("primaryColor")
|
||||
const secondaryColorName = note.get("secondaryColor")
|
||||
|
||||
const primaryColor = primaryColorName ? colors[primaryColorName] : colors.white;
|
||||
const secondaryColor = secondaryColorName ? colors[secondaryColorName] : primaryColor;
|
||||
const icon = note.get("icon") ? icons[note.get("icon")] : undefined;
|
||||
|
||||
return (
|
||||
<Command.Item asChild value={note.get("title")}>
|
||||
<Link to="/app/note/$id" params={{ id: note.get("id") }} className="bg-card shadow shadow-black rounded overflow-hidden relative">
|
||||
<div className="w-full h-[100px]" style={{
|
||||
background: `linear-gradient(135deg, ${primaryColor.base}, ${secondaryColor.base})`
|
||||
}} />
|
||||
{icon && <div className="inline-block bg-zinc-900 absolute top-[58px] left-4 rounded-xl p-2">
|
||||
<Icon path={icon.path} size={2} color={primaryColor.base} />
|
||||
</div>}
|
||||
<div className="px-4 pt-2 pb-4 mt-[22px]">
|
||||
<h1 className="ml-2 font-bold text-xl">{note.get("title")}</h1>
|
||||
</div>
|
||||
</Link>
|
||||
</Command.Item>
|
||||
);
|
||||
}
|
||||
|
||||
type NormalNoteProps = {
|
||||
note: NoteMetadata;
|
||||
};
|
||||
function NormalNote(props: NormalNoteProps) {
|
||||
const { note } = props;
|
||||
|
||||
const colorName = note.get("primaryColor");
|
||||
|
||||
const color = colorName ? colors[colorName] : colors.white;
|
||||
const icon = note.get("icon") ? icons[note.get("icon")] : undefined;
|
||||
|
||||
return (
|
||||
<Command.Item asChild value={note.get("title")}>
|
||||
<Link to="/app/note/$id" params={{ id: note.get("id") }} className="bg-card shadow rounded overflow-hidden flex flex-row">
|
||||
<div className="w-1 h-full" style={{ backgroundColor: color.base }} />
|
||||
<div className="flex flex-col gap-2 px-4 py-2 justify-center">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
{icon && <Icon path={icon.path} size={1.5} color={color.base} />}
|
||||
<h1 className="font-bold text-xl">{note.get("title")}</h1>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Command.Item>
|
||||
);
|
||||
}
|
155
src/components/ui/alert-dialog.tsx
Normal file
155
src/components/ui/alert-dialog.tsx
Normal file
|
@ -0,0 +1,155 @@
|
|||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { buttonVariants } from "~/components/ui/button"
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Overlay
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Action
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Cancel
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
51
src/components/ui/avatar.tsx
Normal file
51
src/components/ui/avatar.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import * as React from "react"
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
175
src/components/ui/command.tsx
Normal file
175
src/components/ui/command.tsx
Normal file
|
@ -0,0 +1,175 @@
|
|||
import * as React from "react"
|
||||
import { Command as CommandPrimitive } from "cmdk"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog"
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string
|
||||
description?: string
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogHeader className="sr-only">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogContent className="overflow-hidden p-0">
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
133
src/components/ui/dialog.tsx
Normal file
133
src/components/ui/dialog.tsx
Normal file
|
@ -0,0 +1,133 @@
|
|||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
255
src/components/ui/dropdown-menu.tsx
Normal file
255
src/components/ui/dropdown-menu.tsx
Normal file
|
@ -0,0 +1,255 @@
|
|||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
22
src/components/ui/label.tsx
Normal file
22
src/components/ui/label.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
46
src/components/ui/popover.tsx
Normal file
46
src/components/ui/popover.tsx
Normal file
|
@ -0,0 +1,46 @@
|
|||
import * as React from "react"
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function Popover({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||
}
|
||||
|
||||
function PopoverTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||
}
|
||||
|
||||
function PopoverContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||
return (
|
||||
<PopoverPrimitive.Portal>
|
||||
<PopoverPrimitive.Content
|
||||
data-slot="popover-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</PopoverPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function PopoverAnchor({
|
||||
...props
|
||||
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||
}
|
||||
|
||||
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
|
45
src/components/ui/radio-group.tsx
Normal file
45
src/components/ui/radio-group.tsx
Normal file
|
@ -0,0 +1,45 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
|
@ -308,7 +308,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
|||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
"md:group-peer-data-[variant=inset]:m-2 md:group-peer-data-[variant=inset]:ml-0 md:group-peer-data-[variant=inset]:rounded-xl md:group-peer-data-[variant=inset]:shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
|
71
src/components/ui/toggle-group.tsx
Normal file
71
src/components/ui/toggle-group.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
import { toggleVariants } from "~/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants>
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
47
src/components/ui/toggle.tsx
Normal file
47
src/components/ui/toggle.tsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TogglePrimitive from "@radix-ui/react-toggle"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-2 min-w-9",
|
||||
sm: "h-8 px-1.5 min-w-8",
|
||||
lg: "h-10 px-2.5 min-w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TogglePrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
return (
|
||||
<TogglePrimitive.Root
|
||||
data-slot="toggle"
|
||||
className={cn(toggleVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants }
|
51
src/components/user/user_avatar.tsx
Normal file
51
src/components/user/user_avatar.tsx
Normal file
|
@ -0,0 +1,51 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar";
|
||||
|
||||
type User = {
|
||||
name?: string;
|
||||
email?: string;
|
||||
image?: string;
|
||||
};
|
||||
|
||||
type UserAvatarProps = {
|
||||
className?: string;
|
||||
user?: User;
|
||||
};
|
||||
|
||||
export function UserAvatar(props: UserAvatarProps) {
|
||||
const email = props.user?.email ?? undefined;
|
||||
const gravatar = useGravatarUrl(email);
|
||||
const avatar = props.user?.image ?? gravatar;
|
||||
const name = props.user?.name ?? undefined
|
||||
const initials = getInitials(name);
|
||||
|
||||
return (
|
||||
<Avatar className={props.className}>
|
||||
<AvatarImage src={avatar} alt={name} />
|
||||
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
|
||||
</Avatar>
|
||||
);
|
||||
}
|
||||
|
||||
function useGravatarUrl(email?: string, size = 80) {
|
||||
const [url, setUrl] = useState<string>();
|
||||
useEffect(() => {
|
||||
if (!email) return;
|
||||
|
||||
const trimmedEmail = email.trim().toLowerCase();
|
||||
crypto.subtle.digest('SHA-256', new TextEncoder().encode(trimmedEmail)).then(hashBuffer => {
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
setUrl(`https://www.gravatar.com/avatar/${hashHex}?s=${size}&d=identicon`);
|
||||
});
|
||||
}, [email, size]);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
function getInitials(name?: string) {
|
||||
if (!name) return "";
|
||||
|
||||
const parts = name.split(" ");
|
||||
return parts.map(part => part[0]).join("");
|
||||
}
|
64
src/components/yjs/metadata-inspector.tsx
Normal file
64
src/components/yjs/metadata-inspector.tsx
Normal file
|
@ -0,0 +1,64 @@
|
|||
import * as Y from "yjs";
|
||||
import { useCollections, useNotesMetadata } from "~/hooks/use-metadata";
|
||||
|
||||
export function MetadataInspector() {
|
||||
const collections = useCollections()
|
||||
const notes = useNotesMetadata()
|
||||
|
||||
return <div>
|
||||
<span>Collections: <YjsInspector toRender={collections} /></span>
|
||||
<span>Notes: <YjsInspector toRender={notes} /></span>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function YjsInspector(props: { toRender: any }) {
|
||||
return <YjsNode node={props.toRender} depth={0} />
|
||||
}
|
||||
|
||||
type YjsNodeProps<T> = {
|
||||
node: T
|
||||
depth: number
|
||||
label?: string
|
||||
}
|
||||
|
||||
function YjsNode(props: YjsNodeProps<any>) {
|
||||
if (props.node instanceof Y.Map) return <YjsMap {...props} />
|
||||
if (props.node instanceof Y.Array) return <YjsArray {...props} />
|
||||
if (typeof props.node == "string") return <String {...props} />
|
||||
|
||||
return <div>
|
||||
{props.label}: {props.node.toString()}
|
||||
</div>
|
||||
}
|
||||
|
||||
function YjsMap(props: YjsNodeProps<Y.Map<any>>) {
|
||||
const entries = Array.from(props.node.entries())
|
||||
return <div>
|
||||
{props.label ? `${props.label}: ` : ""}<span>{"{"}<Badge>YMap</Badge></span>
|
||||
<div style={{ paddingLeft: "2ch" }}>
|
||||
{entries.map(([key, item]) => <YjsNode key={key} node={item} depth={props.depth + 1} label={key} />)}
|
||||
</div>
|
||||
<span>{"}"}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
function YjsArray(props: YjsNodeProps<Y.Array<any>>) {
|
||||
return <div>
|
||||
{props.label ? `${props.label}: ` : ""}<span>{"["}<Badge>YArray</Badge></span>
|
||||
<div style={{ paddingLeft: "2ch" }}>
|
||||
{props.node.map((item, index) => <YjsNode key={index} node={item} depth={props.depth + 1} />)}
|
||||
</div>
|
||||
<span>{"]"}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
function Badge(props: { children: React.ReactNode }) {
|
||||
return <span className="px-1 py-.5 rounded bg-accent ml-1">
|
||||
{props.children}
|
||||
</span>
|
||||
}
|
||||
|
||||
function String(props: YjsNodeProps<string>) {
|
||||
return <div>{props.label}: "{props.node}"</div>
|
||||
}
|
||||
|
90
src/editor/Editor.tsx
Normal file
90
src/editor/Editor.tsx
Normal file
|
@ -0,0 +1,90 @@
|
|||
"use client";
|
||||
|
||||
import * as Y from "yjs";
|
||||
import { type InitialConfigType, LexicalComposer } from "@lexical/react/LexicalComposer";
|
||||
import { PlainTextPlugin } from "@lexical/react/LexicalPlainTextPlugin";
|
||||
import { CollaborationPlugin } from '@lexical/react/LexicalCollaborationPlugin';
|
||||
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
|
||||
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
|
||||
import { HeaderMarkerNode, HeaderNode } from "./nodes/header_node";
|
||||
import { ParagraphPlugin } from "./plugins/paragraph_plugin";
|
||||
import { HeaderPlugin } from "./plugins/header_plugin";
|
||||
import { SelectionPlugin } from "./plugins/selection_plugin";
|
||||
import { FormattedTextMarkerNode, FormattedTextNode } from "./nodes/formatted_text";
|
||||
import { FormattedTextPlugin } from "./plugins/formatted_text_plugin";
|
||||
import { TaskIconNode, TaskMarkerNode, TaskNode } from "./nodes/task_node";
|
||||
import { TaskPlugin } from "./plugins/task_plugin";
|
||||
import { LinkIconNode, LinkMarkerNode, LinkNode, LinkUrlNode } from "./nodes/link_node";
|
||||
import { LinkPlugin } from "./plugins/link_plugin";
|
||||
import { TermIconNode, TermMarkerNode, TermNode } from "./nodes/term_node";
|
||||
import { TermPlugin } from "./plugins/term_plugin";
|
||||
import { useCallback } from "react";
|
||||
import type { Provider } from "@lexical/yjs";
|
||||
import type { NoteId } from "~/lib/metadata";
|
||||
import type { Klass, LexicalNode } from "lexical";
|
||||
import { useNoteDoc, useNoteProviders } from "~/hooks/use-note";
|
||||
|
||||
export function Editor(props: { noteId: NoteId }) {
|
||||
const provider = useNoteProviders().websocket;
|
||||
const doc = useNoteDoc();
|
||||
|
||||
const providerFactory = useCallback((id: string, yjsDocMap: Map<string, Y.Doc>): Provider => {
|
||||
// Just overwrite it, because we have the doc managed externally
|
||||
yjsDocMap.set(id, doc);
|
||||
|
||||
return provider as any as Provider;
|
||||
}, [provider, doc]);
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialEditorConfig}>
|
||||
<div className="w-full relative editor-content">
|
||||
<PlainTextPlugin
|
||||
contentEditable={<ContentEditable className="outline-none" />}
|
||||
placeholder={<div className="absolute top-0 left-0 pointer-events-none text-zinc-300">Enter some text...</div>}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
</div>
|
||||
<CollaborationPlugin
|
||||
id={`note-${props.noteId}`}
|
||||
providerFactory={providerFactory}
|
||||
shouldBootstrap={false}
|
||||
excludedProperties={excludedProperties}
|
||||
/>
|
||||
|
||||
<ParagraphPlugin />
|
||||
<SelectionPlugin />
|
||||
|
||||
<HeaderPlugin />
|
||||
<FormattedTextPlugin />
|
||||
<TaskPlugin />
|
||||
<LinkPlugin />
|
||||
<TermPlugin />
|
||||
</LexicalComposer>
|
||||
);
|
||||
}
|
||||
|
||||
const excludedProperties = new Map<Klass<LexicalNode>, Set<string>>([
|
||||
[HeaderNode, new Set(["__hasFocus"])],
|
||||
[TermNode, new Set(["__hasFocus"])],
|
||||
[TaskNode, new Set(["__hasFocus"])],
|
||||
[LinkNode, new Set(["__hasFocus"])],
|
||||
[FormattedTextNode, new Set(["__hasFocus"])],
|
||||
])
|
||||
|
||||
const theme = {
|
||||
};
|
||||
|
||||
const initialEditorConfig: InitialConfigType = {
|
||||
namespace: "NoteEditor",
|
||||
theme,
|
||||
onError: console.error,
|
||||
editorState: null,
|
||||
nodes: [
|
||||
HeaderNode, HeaderMarkerNode,
|
||||
FormattedTextNode, FormattedTextMarkerNode,
|
||||
TaskNode, TaskMarkerNode, TaskIconNode,
|
||||
LinkNode, LinkMarkerNode, LinkUrlNode, LinkIconNode,
|
||||
TermNode, TermMarkerNode, TermIconNode,
|
||||
],
|
||||
};
|
||||
|
26
src/editor/editor_utils.ts
Normal file
26
src/editor/editor_utils.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { $isRootOrShadowRoot, ElementNode, LexicalNode } from "lexical";
|
||||
import { $isLinkUrlNode } from "./nodes/link_node";
|
||||
import { $isTermNode } from "./nodes/term_node";
|
||||
|
||||
export function findBlockNode(node: LexicalNode): LexicalNode {
|
||||
let toCheck: LexicalNode | null = node
|
||||
while (!isBlockNode(toCheck)) {
|
||||
if ($isRootOrShadowRoot(node)) {
|
||||
console.error("findBlockNode encountered the root node. This should not be possible!");
|
||||
throw new Error("Could not find a block node in the parent chain");
|
||||
}
|
||||
|
||||
if (toCheck === null) {
|
||||
throw new Error("Could not find a block node in the parent chain");
|
||||
}
|
||||
toCheck = toCheck.getParent();
|
||||
}
|
||||
|
||||
return toCheck!;
|
||||
}
|
||||
|
||||
|
||||
export function isBlockNode(node?: LexicalNode | null): boolean {
|
||||
return node instanceof ElementNode && !node.isInline();
|
||||
}
|
||||
|
32
src/editor/nodes/focusable_node.ts
Normal file
32
src/editor/nodes/focusable_node.ts
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { ElementNode, type LexicalNode } from "lexical";
|
||||
|
||||
export class FocusableNode extends ElementNode {
|
||||
__hasFocus: boolean = false;
|
||||
|
||||
setFocus(hasFocus: boolean) {
|
||||
const self = this.getWritable();
|
||||
self.__hasFocus = hasFocus;
|
||||
}
|
||||
|
||||
constructor(key?: string, hasFocus?: boolean) {
|
||||
super(key);
|
||||
hasFocus && (this.__hasFocus = hasFocus);
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const element = document.createElement("div");
|
||||
element.classList.toggle("focus", this.__hasFocus);
|
||||
return element;
|
||||
}
|
||||
|
||||
updateDOM(old: FocusableNode, dom: HTMLElement): boolean {
|
||||
if (this.__hasFocus != old.__hasFocus) {
|
||||
dom.classList.toggle("focus", this.__hasFocus);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function $isFocusableNode(node: LexicalNode): node is FocusableNode {
|
||||
return node instanceof FocusableNode;
|
||||
}
|
109
src/editor/nodes/formatted_text.ts
Normal file
109
src/editor/nodes/formatted_text.ts
Normal file
|
@ -0,0 +1,109 @@
|
|||
import { ElementNode, type LexicalNode, type SerializedElementNode, type Spread } from "lexical";
|
||||
import { FocusableNode } from "./focusable_node";
|
||||
import type { TextFormat } from "../serialized_editor_content";
|
||||
|
||||
type SerializedTextFormatNode = Spread<SerializedElementNode, {
|
||||
style: TextFormat;
|
||||
}>;
|
||||
|
||||
export class FormattedTextNode extends FocusableNode {
|
||||
__internalStyle: TextFormat;
|
||||
|
||||
getStyle(): TextFormat {
|
||||
const self = this.getLatest();
|
||||
return self.__internalStyle;
|
||||
}
|
||||
|
||||
static getType(): string {
|
||||
return "formatted-text";
|
||||
}
|
||||
|
||||
static clone(node: FormattedTextNode): FormattedTextNode {
|
||||
return new FormattedTextNode(node.__internalStyle, node.__key, node.__hasFocus);
|
||||
}
|
||||
|
||||
constructor(style: TextFormat, key?: string, hasFocus?: boolean) {
|
||||
super(key, hasFocus);
|
||||
this.__internalStyle = style;
|
||||
}
|
||||
|
||||
isInline(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const element = super.createDOM();
|
||||
element.classList.add("formatted-text");
|
||||
element.classList.add(`format-${this.__internalStyle}`);
|
||||
return element;
|
||||
}
|
||||
|
||||
updateDOM(old: FormattedTextNode, dom: HTMLElement): boolean {
|
||||
return super.updateDOM(old, dom);
|
||||
}
|
||||
|
||||
exportJSON(): SerializedTextFormatNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: "formatted-text",
|
||||
style: this.__internalStyle,
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedTextFormatNode): FormattedTextNode {
|
||||
return $createFormattedTextNode(serializedNode.style);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export function $createFormattedTextNode(style: TextFormat) {
|
||||
return new FormattedTextNode(style);
|
||||
}
|
||||
|
||||
export function $isFormattedTextNode(node?: LexicalNode | null): node is FormattedTextNode {
|
||||
return node instanceof FormattedTextNode;
|
||||
}
|
||||
|
||||
export class FormattedTextMarkerNode extends ElementNode {
|
||||
static getType(): string {
|
||||
return "formatted-text-marker";
|
||||
}
|
||||
|
||||
static clone(node: FormattedTextMarkerNode): FormattedTextMarkerNode {
|
||||
return new FormattedTextMarkerNode(node.__key);
|
||||
}
|
||||
|
||||
isInline(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const element = document.createElement("span");
|
||||
element.classList.add("formatted-text-marker");
|
||||
return element;
|
||||
}
|
||||
|
||||
updateDOM(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedElementNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: "formatted-text-marker",
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON() {
|
||||
return $createFormattedTextMarkerNode();
|
||||
}
|
||||
}
|
||||
|
||||
export function $createFormattedTextMarkerNode() {
|
||||
return new FormattedTextMarkerNode();
|
||||
}
|
||||
|
||||
export function $isFormattedTextMarkerNode(node?: LexicalNode | null): node is FormattedTextMarkerNode {
|
||||
return node instanceof FormattedTextMarkerNode;
|
||||
}
|
||||
|
108
src/editor/nodes/header_node.ts
Normal file
108
src/editor/nodes/header_node.ts
Normal file
|
@ -0,0 +1,108 @@
|
|||
import { $createParagraphNode, ElementNode, type LexicalNode, ParagraphNode, type RangeSelection, type SerializedElementNode, type Spread } from "lexical";
|
||||
import { FocusableNode } from "./focusable_node";
|
||||
|
||||
type SerializedHeaderNode = Spread<SerializedElementNode, {
|
||||
level: number;
|
||||
}>;
|
||||
|
||||
export class HeaderNode extends FocusableNode {
|
||||
__level: number;
|
||||
|
||||
getLevel() {
|
||||
const self = this.getLatest();
|
||||
return self.__level;
|
||||
}
|
||||
|
||||
static getType() {
|
||||
return "header";
|
||||
}
|
||||
|
||||
static clone(node: HeaderNode) {
|
||||
return new HeaderNode(node.__level, node.__key, node.__hasFocus);
|
||||
}
|
||||
|
||||
constructor(level: number, key?: string, hasFocus?: boolean) {
|
||||
super(key, hasFocus);
|
||||
this.__level = level;
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const element = super.createDOM();
|
||||
element.classList.add("header", `header-${this.__level}`);
|
||||
return element;
|
||||
}
|
||||
|
||||
updateDOM(old: HeaderNode, dom: HTMLElement): boolean {
|
||||
return super.updateDOM(old, dom);
|
||||
}
|
||||
|
||||
insertNewAfter(_selection: RangeSelection, restoreSelection?: boolean): ParagraphNode {
|
||||
const newElement = $createParagraphNode();
|
||||
this.insertAfter(newElement, restoreSelection);
|
||||
return newElement;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedHeaderNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: "header",
|
||||
level: this.__level,
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON(serializedHeaderNode: SerializedHeaderNode) {
|
||||
return $createHeaderNode(serializedHeaderNode.level);
|
||||
}
|
||||
}
|
||||
|
||||
export function $createHeaderNode(level: number) {
|
||||
return new HeaderNode(level);
|
||||
}
|
||||
|
||||
export function $isHeaderNode(node?: LexicalNode | null): node is HeaderNode {
|
||||
return node instanceof HeaderNode;
|
||||
}
|
||||
|
||||
export class HeaderMarkerNode extends ElementNode {
|
||||
static getType() {
|
||||
return "header-marker";
|
||||
}
|
||||
|
||||
static clone(node: HeaderMarkerNode) {
|
||||
return new HeaderMarkerNode(node.__key);
|
||||
}
|
||||
|
||||
isInline(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const element = document.createElement("span");
|
||||
element.classList.add("header-marker");
|
||||
return element;
|
||||
}
|
||||
|
||||
updateDOM(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
exportJSON(): SerializedElementNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: "header-marker",
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON() {
|
||||
return $createHeaderMarkerNode();
|
||||
}
|
||||
}
|
||||
|
||||
export function $createHeaderMarkerNode() {
|
||||
return new HeaderMarkerNode();
|
||||
}
|
||||
|
||||
export function $isHeaderMarkerNode(node?: LexicalNode | null): node is HeaderMarkerNode {
|
||||
return node instanceof HeaderMarkerNode;
|
||||
}
|
||||
|
184
src/editor/nodes/link_node.tsx
Normal file
184
src/editor/nodes/link_node.tsx
Normal file
|
@ -0,0 +1,184 @@
|
|||
import { DecoratorNode, ElementNode, type LexicalNode, type SerializedElementNode, type SerializedLexicalNode, type Spread } from "lexical";
|
||||
import { FocusableNode } from "./focusable_node";
|
||||
import type { ReactNode } from "react";
|
||||
import { LinkIcon } from "~/components/editor/link_icon";
|
||||
|
||||
type SerializedLinkNode = Spread<SerializedElementNode, {
|
||||
url: string;
|
||||
}>;
|
||||
|
||||
export class LinkNode extends FocusableNode {
|
||||
__url: string;
|
||||
|
||||
getUrl(): string {
|
||||
const self = this.getLatest();
|
||||
return self.__url;
|
||||
}
|
||||
|
||||
static getType() {
|
||||
return "link";
|
||||
}
|
||||
static clone(node: LinkNode) {
|
||||
return new LinkNode(node.__url, node.__key, node.__hasFocus);
|
||||
}
|
||||
constructor(url: string, key?: string, hasFocus?: boolean) {
|
||||
super(key, hasFocus);
|
||||
this.__url = url;
|
||||
}
|
||||
|
||||
isInline(): boolean {
|
||||
return true;
|
||||
}
|
||||
createDOM(): HTMLElement {
|
||||
const element = super.createDOM();
|
||||
element.classList.add("link");
|
||||
return element;
|
||||
}
|
||||
updateDOM(old: LinkNode, dom: HTMLElement): boolean {
|
||||
return super.updateDOM(old, dom);
|
||||
}
|
||||
exportJSON(): SerializedLinkNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: "link",
|
||||
url: this.__url,
|
||||
};
|
||||
}
|
||||
static importJSON(serializedNode: SerializedLinkNode) {
|
||||
return $createLinkNode(serializedNode.url);
|
||||
}
|
||||
}
|
||||
|
||||
export function $createLinkNode(url: string) {
|
||||
return new LinkNode(url);
|
||||
}
|
||||
|
||||
export function $isLinkNode(node?: LexicalNode | null): node is LinkNode {
|
||||
return node instanceof LinkNode;
|
||||
}
|
||||
|
||||
export class LinkMarkerNode extends ElementNode {
|
||||
static getType() {
|
||||
return "link-marker";
|
||||
}
|
||||
static clone(node: LinkMarkerNode) {
|
||||
return new LinkMarkerNode(node.__key);
|
||||
}
|
||||
isInline(): boolean {
|
||||
return true;
|
||||
}
|
||||
createDOM(): HTMLElement {
|
||||
const element = document.createElement("span");
|
||||
element.classList.add("link-marker");
|
||||
return element;
|
||||
}
|
||||
updateDOM(): boolean {
|
||||
return false;
|
||||
}
|
||||
exportJSON(): SerializedElementNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: "link-marker",
|
||||
};
|
||||
}
|
||||
static importJSON() {
|
||||
return $createLinkMarkerNode();
|
||||
}
|
||||
}
|
||||
|
||||
export function $createLinkMarkerNode() {
|
||||
return new LinkMarkerNode();
|
||||
}
|
||||
|
||||
export function $isLinkMarkerNode(node?: LexicalNode | null): node is LinkMarkerNode {
|
||||
return node instanceof LinkMarkerNode;
|
||||
}
|
||||
|
||||
export class LinkUrlNode extends ElementNode {
|
||||
static getType() {
|
||||
return "link-url";
|
||||
}
|
||||
static clone(node: LinkUrlNode) {
|
||||
return new LinkUrlNode(node.__key);
|
||||
}
|
||||
isInline(): boolean {
|
||||
return true;
|
||||
}
|
||||
createDOM(): HTMLElement {
|
||||
const element = document.createElement("span");
|
||||
element.classList.add("link-url");
|
||||
return element;
|
||||
}
|
||||
updateDOM(): boolean {
|
||||
return false;
|
||||
}
|
||||
exportJSON(): SerializedElementNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: "link-url",
|
||||
};
|
||||
}
|
||||
static importJSON() {
|
||||
return $createLinkUrlNode();
|
||||
}
|
||||
}
|
||||
|
||||
export function $createLinkUrlNode() {
|
||||
return new LinkUrlNode();
|
||||
}
|
||||
|
||||
export function $isLinkUrlNode(node?: LexicalNode | null): node is LinkUrlNode {
|
||||
return node instanceof LinkUrlNode;
|
||||
}
|
||||
|
||||
type SerializedLinkIconNode = Spread<SerializedLexicalNode, {
|
||||
url: string;
|
||||
}>;
|
||||
|
||||
export class LinkIconNode extends DecoratorNode<ReactNode> {
|
||||
__url: string;
|
||||
|
||||
getUrl() {
|
||||
const self = this.getLatest();
|
||||
return self.__url;
|
||||
}
|
||||
|
||||
static getType() {
|
||||
return "link-icon";
|
||||
}
|
||||
static clone(node: LinkIconNode) {
|
||||
return new LinkIconNode(node.__url, node.__key);
|
||||
}
|
||||
constructor(url: string, key?: string) {
|
||||
super(key);
|
||||
this.__url = url;
|
||||
}
|
||||
createDOM(): HTMLElement {
|
||||
return document.createElement("span");
|
||||
}
|
||||
updateDOM(): boolean {
|
||||
return false;
|
||||
}
|
||||
decorate(): ReactNode {
|
||||
return (<LinkIcon url={this.__url} />);
|
||||
}
|
||||
exportJSON(): SerializedLinkIconNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: "link-icon",
|
||||
url: this.__url,
|
||||
}
|
||||
}
|
||||
static importJSON(serializedNode: SerializedLinkIconNode) {
|
||||
return $createLinkIconNode(serializedNode.url);
|
||||
}
|
||||
}
|
||||
|
||||
export function $createLinkIconNode(url: string) {
|
||||
return new LinkIconNode(url);
|
||||
}
|
||||
|
||||
export function $isLinkIconNode(node?: LexicalNode | null): node is LinkIconNode {
|
||||
return node instanceof LinkIconNode;
|
||||
}
|
||||
|
159
src/editor/nodes/task_node.tsx
Normal file
159
src/editor/nodes/task_node.tsx
Normal file
|
@ -0,0 +1,159 @@
|
|||
import { $createParagraphNode, DecoratorNode, ElementNode, type LexicalNode, ParagraphNode, type RangeSelection, type SerializedElementNode, type SerializedLexicalNode, type Spread } from "lexical";
|
||||
import { FocusableNode } from "./focusable_node";
|
||||
import type { ReactNode } from "react";
|
||||
import { type TaskType, taskTypes } from "../plugins/task_plugin";
|
||||
import { TaskIcon } from "~/components/editor/task_icon";
|
||||
import type { TaskLabel } from "../serialized_editor_content";
|
||||
|
||||
type SerializedTaskNode = Spread<SerializedElementNode, {
|
||||
taskType: TaskLabel;
|
||||
}>;
|
||||
|
||||
export class TaskNode extends FocusableNode {
|
||||
__taskType: TaskType;
|
||||
|
||||
getTaskType() {
|
||||
const self = this.getLatest();
|
||||
return self.__taskType;
|
||||
}
|
||||
|
||||
static getType() {
|
||||
return "task";
|
||||
}
|
||||
static clone(node: TaskNode) {
|
||||
return new TaskNode(node.__taskType, node.__key, node.__hasFocus);
|
||||
}
|
||||
|
||||
constructor(taskType: TaskType, key?: string, hasFocus?: boolean) {
|
||||
super(key, hasFocus);
|
||||
this.__taskType = taskType;
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const element = super.createDOM();
|
||||
element.classList.add("task");
|
||||
element.setAttribute("data-task-type", this.__taskType.label);
|
||||
return element;
|
||||
}
|
||||
updateDOM(old: TaskNode, dom: HTMLElement): boolean {
|
||||
return super.updateDOM(old, dom);
|
||||
}
|
||||
insertNewAfter(_selection: RangeSelection, restoreSelection?: boolean): ParagraphNode {
|
||||
const newElement = $createParagraphNode();
|
||||
this.insertAfter(newElement, restoreSelection);
|
||||
return newElement;
|
||||
}
|
||||
exportJSON(): SerializedTaskNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: "task",
|
||||
taskType: this.__taskType.label,
|
||||
};
|
||||
}
|
||||
static importJSON(serializedTaskNode: SerializedTaskNode) {
|
||||
const taskType = taskTypes.find(it => it.label == serializedTaskNode.taskType);
|
||||
// FIXME: Deal with task type not existing gracefully. Uncreate the node?
|
||||
return $createTaskNode(taskType!);
|
||||
}
|
||||
}
|
||||
|
||||
export function $createTaskNode(taskType: TaskType) {
|
||||
return new TaskNode(taskType);
|
||||
}
|
||||
|
||||
export function $isTaskNode(node?: LexicalNode | null): node is TaskNode {
|
||||
return node instanceof TaskNode;
|
||||
}
|
||||
|
||||
export class TaskMarkerNode extends ElementNode {
|
||||
static getType() {
|
||||
return "task-marker";
|
||||
}
|
||||
static clone(node: TaskMarkerNode) {
|
||||
return new TaskMarkerNode(node.__key);
|
||||
}
|
||||
isInline(): boolean {
|
||||
return true;
|
||||
}
|
||||
createDOM(): HTMLElement {
|
||||
const element = document.createElement("span");
|
||||
element.classList.add("task-marker");
|
||||
return element;
|
||||
}
|
||||
updateDOM(): boolean {
|
||||
return false;
|
||||
}
|
||||
exportJSON() {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: "task-marker",
|
||||
};
|
||||
}
|
||||
static importJSON() {
|
||||
return $createTaskMarkerNode();
|
||||
}
|
||||
}
|
||||
|
||||
export function $createTaskMarkerNode() {
|
||||
return new TaskMarkerNode();
|
||||
}
|
||||
|
||||
export function $isTaskMarkerNode(node?: LexicalNode | null): node is TaskMarkerNode {
|
||||
return node instanceof TaskMarkerNode;
|
||||
}
|
||||
|
||||
|
||||
type SerializedTaskIconNode = Spread<SerializedLexicalNode, {
|
||||
taskType: TaskLabel;
|
||||
}>;
|
||||
|
||||
export class TaskIconNode extends DecoratorNode<ReactNode> {
|
||||
__taskType: TaskType;
|
||||
|
||||
getTaskType() {
|
||||
const self = this.getLatest();
|
||||
return self.__taskType;
|
||||
}
|
||||
|
||||
static getType() {
|
||||
return "task-icon";
|
||||
}
|
||||
static clone(node: TaskIconNode) {
|
||||
return new TaskIconNode(node.__taskType, node.__key);
|
||||
}
|
||||
|
||||
constructor(taskType: TaskType, key?: string) {
|
||||
super(key);
|
||||
this.__taskType = taskType;
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
return document.createElement("span");
|
||||
}
|
||||
updateDOM(): boolean {
|
||||
return false;
|
||||
}
|
||||
decorate(): ReactNode {
|
||||
return (<TaskIcon icon={this.__taskType.icon?.path} />);
|
||||
}
|
||||
exportJSON(): SerializedTaskIconNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: "task-icon",
|
||||
taskType: this.__taskType.label,
|
||||
};
|
||||
}
|
||||
static importJSON(serializedTaskIconNode: SerializedTaskIconNode) {
|
||||
const taskType = taskTypes.find(it => it.label == serializedTaskIconNode.taskType);
|
||||
// FIXME: Deal with task type not existing gracefully. Uncreate the node?
|
||||
return $createTaskIconNode(taskType!);
|
||||
}
|
||||
}
|
||||
|
||||
export function $createTaskIconNode(taskType: TaskType) {
|
||||
return new TaskIconNode(taskType);
|
||||
}
|
||||
|
||||
export function $isTaskIconNode(node?: LexicalNode | null): node is TaskIconNode {
|
||||
return node instanceof TaskIconNode;
|
||||
}
|
142
src/editor/nodes/term_node.tsx
Normal file
142
src/editor/nodes/term_node.tsx
Normal file
|
@ -0,0 +1,142 @@
|
|||
import { DecoratorNode, ElementNode, type LexicalNode, type SerializedElementNode, type SerializedLexicalNode, type Spread } from "lexical";
|
||||
import { FocusableNode } from "./focusable_node";
|
||||
import type { ReactNode } from "react";
|
||||
import { TermIcon } from "~/components/editor/term_icon";
|
||||
|
||||
type SerializedTermNode = Spread<SerializedElementNode, {
|
||||
term: string;
|
||||
}>;
|
||||
|
||||
export class TermNode extends FocusableNode {
|
||||
__term: string;
|
||||
|
||||
getTerm() {
|
||||
const self = this.getLatest();
|
||||
return self.__term;
|
||||
}
|
||||
static getType() {
|
||||
return "term";
|
||||
}
|
||||
static clone(node: TermNode) {
|
||||
return new TermNode(node.__term, node.__key, node.__hasFocus);
|
||||
}
|
||||
constructor(term: string, key?: string, hasFocus?: boolean) {
|
||||
super(key, hasFocus);
|
||||
this.__term = term;
|
||||
}
|
||||
isInline() {
|
||||
return true;
|
||||
}
|
||||
createDOM() {
|
||||
const element = super.createDOM();
|
||||
element.classList.add("term");
|
||||
return element;
|
||||
}
|
||||
updateDOM(old: TermNode, dom: HTMLElement) {
|
||||
return super.updateDOM(old, dom);
|
||||
}
|
||||
exportJSON(): SerializedTermNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: "term",
|
||||
term: this.__term,
|
||||
};
|
||||
}
|
||||
static importJSON(serializedNode: SerializedTermNode) {
|
||||
return $createTermNode(serializedNode.term);
|
||||
}
|
||||
}
|
||||
|
||||
export function $createTermNode(term: string) {
|
||||
return new TermNode(term);
|
||||
}
|
||||
export function $isTermNode(node?: LexicalNode | null): node is TermNode {
|
||||
return node instanceof TermNode;
|
||||
}
|
||||
|
||||
export class TermMarkerNode extends ElementNode {
|
||||
static getType() {
|
||||
return "term-marker";
|
||||
}
|
||||
static clone(node: TermMarkerNode) {
|
||||
return new TermMarkerNode(node.__key);
|
||||
}
|
||||
isInline() {
|
||||
return true;
|
||||
}
|
||||
createDOM() {
|
||||
const element = document.createElement("span");
|
||||
element.classList.add("term-marker");
|
||||
return element;
|
||||
}
|
||||
updateDOM() {
|
||||
return false;
|
||||
}
|
||||
exportJSON() {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: "term-marker",
|
||||
};
|
||||
}
|
||||
static importJSON() {
|
||||
return $createTermMarkerNode();
|
||||
}
|
||||
}
|
||||
|
||||
export function $createTermMarkerNode() {
|
||||
return new TermMarkerNode();
|
||||
}
|
||||
export function $isTermMarkerNode(node?: LexicalNode | null): node is TermMarkerNode {
|
||||
return node instanceof TermMarkerNode;
|
||||
}
|
||||
|
||||
type SerializedTermIconNode = Spread<SerializedLexicalNode, {
|
||||
term: string;
|
||||
}>;
|
||||
|
||||
export class TermIconNode extends DecoratorNode<ReactNode> {
|
||||
__term: string;
|
||||
|
||||
getTerm() {
|
||||
const self = this.getLatest();
|
||||
return self.__term;
|
||||
}
|
||||
|
||||
static getType() {
|
||||
return "term-icon";
|
||||
}
|
||||
static clone(node: TermIconNode) {
|
||||
return new TermIconNode(node.__term, node.__key);
|
||||
}
|
||||
constructor(term: string, key?: string) {
|
||||
super(key);
|
||||
this.__term = term;
|
||||
}
|
||||
createDOM() {
|
||||
return document.createElement("span");
|
||||
}
|
||||
updateDOM() {
|
||||
return false;
|
||||
}
|
||||
decorate(): ReactNode {
|
||||
return (<TermIcon term={this.__term} />);
|
||||
}
|
||||
exportJSON(): SerializedTermIconNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: "term-icon",
|
||||
term: this.__term,
|
||||
};
|
||||
}
|
||||
static importJSON(serializedNode: SerializedTermIconNode) {
|
||||
return $createTermIconNode(serializedNode.term);
|
||||
}
|
||||
}
|
||||
|
||||
export function $createTermIconNode(term: string) {
|
||||
return new TermIconNode(term);
|
||||
}
|
||||
export function $isTermIconNode(node?: LexicalNode | null): node is TermIconNode {
|
||||
return node instanceof TermIconNode;
|
||||
}
|
||||
|
102
src/editor/plugins/formatted_text_plugin.tsx
Normal file
102
src/editor/plugins/formatted_text_plugin.tsx
Normal file
|
@ -0,0 +1,102 @@
|
|||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
||||
import { mergeRegister } from "@lexical/utils";
|
||||
import { type LexicalEditor, SELECTION_CHANGE_COMMAND, TextNode } from "lexical";
|
||||
import { useEffect } from "react";
|
||||
import { $createFormattedTextMarkerNode, $createFormattedTextNode, $isFormattedTextMarkerNode, $isFormattedTextNode, FormattedTextMarkerNode, FormattedTextNode } from "../nodes/formatted_text";
|
||||
import { type TextFormat, textFormats } from "../serialized_editor_content";
|
||||
|
||||
export function FormattedTextPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => mergeRegister(
|
||||
// Create formatted text
|
||||
...Object.keys(textFormats).map((format) =>
|
||||
editor.registerNodeTransform(TextNode, createFormattedTextNode(format as TextFormat, editor))
|
||||
),
|
||||
// Remove formatted text marker nodes when not inside formatted text node
|
||||
editor.registerNodeTransform(FormattedTextMarkerNode, (node) => {
|
||||
const parent = node.getParent();
|
||||
if (!parent) return;
|
||||
|
||||
if ($isFormattedTextNode(parent)) {
|
||||
const format = parent.getStyle();
|
||||
const markerChars = textFormats[format];
|
||||
if (node.getTextContent() === markerChars) return;
|
||||
}
|
||||
|
||||
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
|
||||
node.remove();
|
||||
}),
|
||||
// Remove formatted text nodes without matching markers
|
||||
editor.registerNodeTransform(FormattedTextNode, (node) => {
|
||||
const format = node.getStyle();
|
||||
const markerChars = textFormats[format];
|
||||
|
||||
const firstMarker = node.getFirstChild();
|
||||
const lastMarker = node.getLastChild();
|
||||
if (
|
||||
!$isFormattedTextMarkerNode(firstMarker) ||
|
||||
!$isFormattedTextMarkerNode(lastMarker)
|
||||
) {
|
||||
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
|
||||
node.remove();
|
||||
return;
|
||||
}
|
||||
const firstMarkerContent = firstMarker.getTextContent();
|
||||
const lastMarkerContent = lastMarker.getTextContent();
|
||||
|
||||
if (firstMarkerContent !== markerChars || lastMarkerContent !== markerChars) {
|
||||
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
|
||||
node.remove();
|
||||
return;
|
||||
}
|
||||
}),
|
||||
// Remove formatted text nodes without content
|
||||
editor.registerNodeTransform(FormattedTextNode, (node) => {
|
||||
const format = node.getStyle();
|
||||
const formattedChars = textFormats[format];
|
||||
|
||||
const content = node.getTextContent();
|
||||
if (content !== formattedChars + formattedChars) return;
|
||||
|
||||
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
|
||||
node.remove();
|
||||
}),
|
||||
));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const createFormattedTextNode = (format: TextFormat, editor: LexicalEditor) => (node: TextNode) => {
|
||||
const markerChars = textFormats[format];
|
||||
const markerCharsLen = markerChars.length;
|
||||
|
||||
const content = node.getTextContent();
|
||||
|
||||
// TODO: search in all text nodes within the parent block node
|
||||
const start = content.indexOf(markerChars);
|
||||
if (start === -1) return;
|
||||
const end = content.indexOf(markerChars, start + markerCharsLen);
|
||||
if (end === -1) return;
|
||||
if (start === end - markerCharsLen) return;
|
||||
|
||||
const startIndex = start > 0 ? 1 : 0;
|
||||
const contentIndex = startIndex + 1;
|
||||
const endIndex = contentIndex + 1;
|
||||
|
||||
const textNodes = node.splitText(start, start + markerCharsLen, end, end + markerCharsLen);
|
||||
|
||||
const formattedTextNode = $createFormattedTextNode(format);
|
||||
textNodes[startIndex]!.insertBefore(formattedTextNode);
|
||||
|
||||
|
||||
const startMarker = $createFormattedTextMarkerNode();
|
||||
startMarker.append(textNodes[startIndex]!);
|
||||
|
||||
const endMarker = $createFormattedTextMarkerNode();
|
||||
endMarker.append(textNodes[endIndex]!);
|
||||
|
||||
formattedTextNode.append(startMarker, textNodes[contentIndex]!, endMarker);
|
||||
|
||||
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
|
||||
}
|
78
src/editor/plugins/header_plugin.tsx
Normal file
78
src/editor/plugins/header_plugin.tsx
Normal file
|
@ -0,0 +1,78 @@
|
|||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
||||
import { $createParagraphNode, $isParagraphNode, $isTextNode, SELECTION_CHANGE_COMMAND, TextNode } from "lexical";
|
||||
import { useEffect } from "react";
|
||||
import { $createHeaderMarkerNode, $createHeaderNode, $isHeaderMarkerNode, $isHeaderNode, HeaderMarkerNode, HeaderNode } from "../nodes/header_node";
|
||||
import { mergeRegister } from "@lexical/utils";
|
||||
|
||||
const HEADER_REGEX = /^#+ /;
|
||||
|
||||
export function HeaderPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => mergeRegister(
|
||||
// Create a header node if the text node matches the HEADER_REGEX
|
||||
editor.registerNodeTransform(TextNode, (textNode) => {
|
||||
const prevNode = textNode.getPreviousSibling();
|
||||
if (prevNode) return;
|
||||
const paragraphNode = textNode.getParent();
|
||||
if (!$isParagraphNode(paragraphNode)) return;
|
||||
|
||||
const content = textNode.getTextContent();
|
||||
const regexMatch = content.match(HEADER_REGEX);
|
||||
if (!regexMatch) return;
|
||||
|
||||
const children = paragraphNode.getChildren();
|
||||
|
||||
const firstTextNode = children[0];
|
||||
if (!$isTextNode(firstTextNode)) return;
|
||||
|
||||
const markerLength = regexMatch[0].length;
|
||||
const textNodes = firstTextNode.splitText(markerLength);
|
||||
|
||||
const headerMarkerContent = textNodes[0];
|
||||
if (!headerMarkerContent) return;
|
||||
|
||||
const headerNode = $createHeaderNode(markerLength - 1);
|
||||
const headerMarkerNode = $createHeaderMarkerNode();
|
||||
|
||||
headerMarkerNode.append(headerMarkerContent);
|
||||
|
||||
headerNode.append(headerMarkerNode);
|
||||
headerNode.append(...textNodes.slice(1));
|
||||
headerNode.append(...children.slice(1));
|
||||
|
||||
paragraphNode.replace(headerNode, true);
|
||||
|
||||
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
|
||||
}),
|
||||
// Remove headers if they don't match the HEADER_REGEX
|
||||
editor.registerNodeTransform(HeaderNode, (headerNode) => {
|
||||
const content = headerNode.getTextContent();
|
||||
if (content.match(HEADER_REGEX)) return;
|
||||
headerNode.replace($createParagraphNode(), true);
|
||||
}),
|
||||
// Remove header markers if they don't match the HEADER_REGEX
|
||||
editor.registerNodeTransform(HeaderMarkerNode, (node) => {
|
||||
const headerNode = node.getParent();
|
||||
const content = node.getTextContent();
|
||||
if ($isHeaderNode(headerNode) &&
|
||||
content.match(HEADER_REGEX) &&
|
||||
content.length - 1 == headerNode.getLevel()) {
|
||||
return;
|
||||
}
|
||||
|
||||
node.getChildren().reverse().forEach(child => node.insertAfter(child));
|
||||
node.remove();
|
||||
}),
|
||||
// Remove header nodes without a header marker
|
||||
editor.registerNodeTransform(HeaderNode, (node) => {
|
||||
const children = node.getChildren();
|
||||
const headerMarker = children[0];
|
||||
if ($isHeaderMarkerNode(headerMarker)) return;
|
||||
|
||||
node.replace($createParagraphNode(), true);
|
||||
}),
|
||||
));
|
||||
|
||||
return null;
|
||||
}
|
136
src/editor/plugins/link_plugin.tsx
Normal file
136
src/editor/plugins/link_plugin.tsx
Normal file
|
@ -0,0 +1,136 @@
|
|||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
||||
import { mergeRegister } from "@lexical/utils";
|
||||
import { SELECTION_CHANGE_COMMAND, TextNode } from "lexical";
|
||||
import { useEffect } from "react";
|
||||
import { $createLinkIconNode, $createLinkMarkerNode, $createLinkNode, $createLinkUrlNode, $isLinkIconNode, $isLinkMarkerNode, $isLinkNode, $isLinkUrlNode, LinkIconNode, LinkMarkerNode, LinkNode, LinkUrlNode } from "../nodes/link_node";
|
||||
|
||||
const LINK_REGEX = /\[(.+)\]\((.*)\)/;
|
||||
|
||||
export function LinkPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => mergeRegister(
|
||||
// Create link node
|
||||
editor.registerNodeTransform(TextNode, (node) => {
|
||||
const content = node.getTextContent();
|
||||
const matches = content.match(LINK_REGEX);
|
||||
if (!matches) return;
|
||||
|
||||
const labelLength = matches[1]!.length;
|
||||
const urlLength = matches[2]!.length;
|
||||
|
||||
// start
|
||||
// | labelStart
|
||||
// | | labelEnd
|
||||
// | | | middle
|
||||
// | | | | urlStart
|
||||
// | | | | | urlEnd
|
||||
// | | | | | | end Index of the segment
|
||||
// | | | | | | | in the textNodes array
|
||||
// 0|1|2 |3|4|5 |6|7 <--|
|
||||
// |[|label|]|(|url|)|
|
||||
const start = content.indexOf(matches[0]);
|
||||
const labelStart = start + 1;
|
||||
const labelEnd = labelStart + labelLength;
|
||||
const middle = labelEnd + 1;
|
||||
const urlStart = middle + 1;
|
||||
const urlEnd = urlStart + urlLength;
|
||||
const end = urlEnd + 1;
|
||||
|
||||
const textNodes = node.splitText(start, labelStart, labelEnd, middle, urlStart, urlEnd, end);
|
||||
const labelStartIndex = start > 0 ? 1 : 0;
|
||||
const labelIndex = labelStartIndex + 1;
|
||||
const labelEndIndex = labelIndex + 1;
|
||||
const urlStartIndex = labelEndIndex + 1;
|
||||
const urlIndex = urlStartIndex + 1;
|
||||
const urlEndIndex = urlIndex + 1;
|
||||
|
||||
const url = matches[2]!;
|
||||
|
||||
const linkNode = $createLinkNode(url);
|
||||
textNodes[labelStartIndex]!.insertBefore(linkNode);
|
||||
|
||||
const labelStartNode = $createLinkMarkerNode();
|
||||
labelStartNode.append(textNodes[labelStartIndex]!);
|
||||
const labelEndNode = $createLinkMarkerNode();
|
||||
labelEndNode.append(textNodes[labelEndIndex]!);
|
||||
const urlStartNode = $createLinkMarkerNode();
|
||||
urlStartNode.append(textNodes[urlStartIndex]!);
|
||||
const urlNode = $createLinkUrlNode();
|
||||
urlNode.append(textNodes[urlIndex]!);
|
||||
const urlEndNode = $createLinkMarkerNode();
|
||||
urlEndNode.append(textNodes[urlEndIndex]!);
|
||||
const iconNode = $createLinkIconNode(url);
|
||||
|
||||
linkNode.append(labelStartNode, textNodes[labelIndex]!, labelEndNode, urlStartNode, urlNode, urlEndNode, iconNode);
|
||||
|
||||
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
|
||||
}),
|
||||
// Remove dangling link marker nodes and verify their content
|
||||
editor.registerNodeTransform(LinkMarkerNode, (node) => {
|
||||
const linkNode = node.getParent();
|
||||
if ($isLinkNode(linkNode)) {
|
||||
const content = node.getTextContent();
|
||||
const index = node.getIndexWithinParent();
|
||||
const parentChildCount = linkNode.getChildren().length;
|
||||
|
||||
if (index === 0 && content === "[") return;
|
||||
if (index === parentChildCount - 5 && content === "]") return;
|
||||
if (index === parentChildCount - 4 && content === "(") return;
|
||||
if (index === parentChildCount - 2 && content === ")") return;
|
||||
}
|
||||
|
||||
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
|
||||
node.remove();
|
||||
}),
|
||||
// Remove dangling link url nodes
|
||||
editor.registerNodeTransform(LinkUrlNode, (node) => {
|
||||
const linkNode = node.getParent();
|
||||
if ($isLinkNode(linkNode)) {
|
||||
const url = linkNode.getUrl();
|
||||
const content = node.getTextContent();
|
||||
if (url === content) return;
|
||||
}
|
||||
|
||||
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
|
||||
node.remove();
|
||||
}),
|
||||
// Remove dangling link icon nodes
|
||||
editor.registerNodeTransform(LinkIconNode, (node) => {
|
||||
const linkNode = node.getParent();
|
||||
if ($isLinkNode(linkNode)) {
|
||||
const url = linkNode.getUrl();
|
||||
const iconUrl = node.getUrl();
|
||||
if (url === iconUrl) return;
|
||||
}
|
||||
|
||||
node.remove();
|
||||
}),
|
||||
// Remove links with missing markers
|
||||
editor.registerNodeTransform(LinkNode, (node) => {
|
||||
const children = node.getChildren();
|
||||
const startLabel = children.at(0);
|
||||
const endLabel = children.at(-5);
|
||||
const startUrl = children.at(-4);
|
||||
const url = children.at(-3);
|
||||
const endUrl = children.at(-2);
|
||||
const icon = children.at(-1);
|
||||
|
||||
if (
|
||||
$isLinkMarkerNode(startLabel) &&
|
||||
$isLinkMarkerNode(endLabel) &&
|
||||
$isLinkMarkerNode(startUrl) &&
|
||||
$isLinkUrlNode(url) &&
|
||||
$isLinkMarkerNode(endUrl) &&
|
||||
$isLinkIconNode(icon)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
children.reverse().forEach((child) => node.insertAfter(child));
|
||||
node.remove();
|
||||
}),
|
||||
))
|
||||
|
||||
return null;
|
||||
}
|
48
src/editor/plugins/paragraph_plugin.tsx
Normal file
48
src/editor/plugins/paragraph_plugin.tsx
Normal file
|
@ -0,0 +1,48 @@
|
|||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
||||
import { CAN_USE_BEFORE_INPUT, IS_APPLE_WEBKIT, IS_IOS, IS_SAFARI, mergeRegister } from "@lexical/utils";
|
||||
import { $getSelection, $isRangeSelection, COMMAND_PRIORITY_NORMAL, INSERT_PARAGRAPH_COMMAND, KEY_ENTER_COMMAND } from "lexical";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function ParagraphPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => mergeRegister(
|
||||
editor.registerCommand(INSERT_PARAGRAPH_COMMAND, () => {
|
||||
const selection = $getSelection();
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
selection.insertParagraph();
|
||||
return true;
|
||||
}, COMMAND_PRIORITY_NORMAL),
|
||||
editor.registerCommand<KeyboardEvent | null>(
|
||||
KEY_ENTER_COMMAND,
|
||||
(event) => {
|
||||
const selection = $getSelection();
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return false;
|
||||
}
|
||||
if (event !== null) {
|
||||
// If we have beforeinput, then we can avoid blocking
|
||||
// the default behavior. This ensures that the iOS can
|
||||
// intercept that we're actually inserting a paragraph,
|
||||
// and autocomplete, autocapitalize etc work as intended.
|
||||
// This can also cause a strange performance issue in
|
||||
// Safari, where there is a noticeable pause due to
|
||||
// preventing the key down of enter.
|
||||
if (
|
||||
(IS_IOS || IS_SAFARI || IS_APPLE_WEBKIT) &&
|
||||
CAN_USE_BEFORE_INPUT
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
}
|
||||
return editor.dispatchCommand(INSERT_PARAGRAPH_COMMAND, undefined);
|
||||
},
|
||||
COMMAND_PRIORITY_NORMAL,
|
||||
),
|
||||
))
|
||||
|
||||
return null;
|
||||
}
|
49
src/editor/plugins/selection_plugin.tsx
Normal file
49
src/editor/plugins/selection_plugin.tsx
Normal file
|
@ -0,0 +1,49 @@
|
|||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
||||
import { mergeRegister } from "@lexical/utils";
|
||||
import { $getNodeByKey, $getSelection, COMMAND_PRIORITY_EDITOR, type LexicalNode, SELECTION_CHANGE_COMMAND } from "lexical";
|
||||
import { useEffect, useState } from "react";
|
||||
import { $isFocusableNode } from "../nodes/focusable_node";
|
||||
|
||||
export function SelectionPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
const [previousSelectedNodes, setPreviousSelectedNodes] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => mergeRegister(
|
||||
editor.registerCommand(SELECTION_CHANGE_COMMAND, () => {
|
||||
const selection = $getSelection();
|
||||
const nodes = selection?.getNodes() || [];
|
||||
|
||||
previousSelectedNodes
|
||||
.map((key) => $getNodeByKey(key))
|
||||
.forEach(markTreeAsUnfocused);
|
||||
|
||||
nodes.forEach(markTreeAsFocused);
|
||||
setPreviousSelectedNodes(nodes.map(n => n.getKey()));
|
||||
|
||||
return false;
|
||||
}, COMMAND_PRIORITY_EDITOR),
|
||||
));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
function markTreeAsFocused(node: LexicalNode | null) {
|
||||
if (!node) return;
|
||||
|
||||
if ($isFocusableNode(node)) {
|
||||
node.setFocus(true);
|
||||
}
|
||||
|
||||
markTreeAsFocused(node.getParent());
|
||||
}
|
||||
|
||||
function markTreeAsUnfocused(node: LexicalNode | null) {
|
||||
if (!node) return;
|
||||
|
||||
if ($isFocusableNode(node)) {
|
||||
node.setFocus(false);
|
||||
}
|
||||
|
||||
markTreeAsUnfocused(node.getParent());
|
||||
}
|
116
src/editor/plugins/task_plugin.tsx
Normal file
116
src/editor/plugins/task_plugin.tsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
||||
import { mergeRegister } from "@lexical/utils";
|
||||
import { $createParagraphNode, $isParagraphNode, $isTextNode, type LexicalEditor, SELECTION_CHANGE_COMMAND, TextNode } from "lexical";
|
||||
import { useEffect } from "react";
|
||||
import { $createTaskMarkerNode, $createTaskNode, $createTaskIconNode, TaskMarkerNode, $isTaskNode, TaskIconNode, TaskNode, $isTaskIconNode, $isTaskMarkerNode } from "../nodes/task_node";
|
||||
import { type Icon, icons } from "~/lib/icon";
|
||||
import { type TaskLabel } from "../serialized_editor_content";
|
||||
|
||||
export type TaskType = {
|
||||
label: TaskLabel
|
||||
icon?: Icon
|
||||
}
|
||||
|
||||
export const taskTypes: TaskType[] = [
|
||||
{
|
||||
label: "TODO",
|
||||
icon: undefined,
|
||||
},
|
||||
{
|
||||
label: "DOING",
|
||||
icon: icons.minus,
|
||||
},
|
||||
{
|
||||
label: "DONE",
|
||||
icon: icons.check,
|
||||
},
|
||||
{
|
||||
label: "IDEA",
|
||||
icon: icons.lightbulb,
|
||||
},
|
||||
{
|
||||
label: "DEADLINE",
|
||||
icon: icons.exclamation,
|
||||
},
|
||||
]
|
||||
|
||||
export function TaskPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => mergeRegister(
|
||||
// Create a task node if the text node matches the TASK_REGEX
|
||||
...taskTypes.map(({ label }) =>
|
||||
editor.registerNodeTransform(TextNode, createTaskNode(label, editor))
|
||||
),
|
||||
// Remove task marker nodes not inside a task node or with the wrong content
|
||||
editor.registerNodeTransform(TaskMarkerNode, (node) => {
|
||||
const taskNode = node.getParent();
|
||||
if ($isTaskNode(taskNode)) {
|
||||
const taskType = taskNode.getTaskType();
|
||||
const content = node.getTextContent();
|
||||
if (content === taskType.label) return;
|
||||
}
|
||||
|
||||
node.getChildren().reverse().forEach(child => node.insertAfter(child));
|
||||
node.remove();
|
||||
}),
|
||||
// Remove task icon nodes not inside a task node
|
||||
editor.registerNodeTransform(TaskIconNode, (node) => {
|
||||
const taskNode = node.getParent();
|
||||
if ($isTaskNode(taskNode)) {
|
||||
const taskType = taskNode.getTaskType();
|
||||
if (node.getTaskType() === taskType) return;
|
||||
}
|
||||
|
||||
node.remove();
|
||||
}),
|
||||
// Remove task nodes without an icon or marker
|
||||
editor.registerNodeTransform(TaskNode, (node) => {
|
||||
const iconNode = node.getChildAtIndex(0);
|
||||
const markerNode = node.getChildAtIndex(1);
|
||||
|
||||
if ($isTaskIconNode(iconNode) && $isTaskMarkerNode(markerNode)) return;
|
||||
|
||||
node.replace($createParagraphNode(), true);
|
||||
}),
|
||||
));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const createTaskNode = (label: TaskLabel, editor: LexicalEditor) => (node: TextNode) => {
|
||||
const prevNode = node.getPreviousSibling();
|
||||
if (prevNode) return;
|
||||
const paragraphNode = node.getParent();
|
||||
if (!$isParagraphNode(paragraphNode)) return;
|
||||
|
||||
const content = node.getTextContent();
|
||||
if (!content.startsWith(label + " ")) return;
|
||||
|
||||
const children = paragraphNode.getChildren();
|
||||
|
||||
const firstTextNode = children[0];
|
||||
if (!$isTextNode(firstTextNode)) return;
|
||||
|
||||
const textNodes = firstTextNode.splitText(label.length);
|
||||
|
||||
const todoMarkerContent = textNodes[0];
|
||||
if (!todoMarkerContent) return;
|
||||
|
||||
const taskType = taskTypes.find(it => it.label == label);
|
||||
if (!taskType) return;
|
||||
|
||||
const taskNode = $createTaskNode(taskType);
|
||||
const taskIconNode = $createTaskIconNode(taskType);
|
||||
const taskMarkerNode = $createTaskMarkerNode();
|
||||
|
||||
taskMarkerNode.append(todoMarkerContent);
|
||||
|
||||
taskNode.append(taskIconNode);
|
||||
taskNode.append(taskMarkerNode);
|
||||
taskNode.append(...textNodes.slice(1));
|
||||
taskNode.append(...children.slice(1));
|
||||
|
||||
paragraphNode.replace(taskNode, true);
|
||||
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
|
||||
}
|
83
src/editor/plugins/term_plugin.tsx
Normal file
83
src/editor/plugins/term_plugin.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
||||
import { mergeRegister } from "@lexical/utils";
|
||||
import { SELECTION_CHANGE_COMMAND, TextNode } from "lexical";
|
||||
import { useEffect } from "react";
|
||||
import { $createTermIconNode, $createTermMarkerNode, $createTermNode, $isTermIconNode, $isTermMarkerNode, $isTermNode, TermIconNode, TermMarkerNode, TermNode } from "../nodes/term_node";
|
||||
|
||||
const TERM_REGEX = /\[\[(.+)\]\]/;
|
||||
|
||||
export function TermPlugin() {
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
||||
useEffect(() => mergeRegister(
|
||||
editor.registerNodeTransform(TextNode, (node) => {
|
||||
const content = node.getTextContent();
|
||||
const matches = content.match(TERM_REGEX);
|
||||
if (!matches) return;
|
||||
|
||||
const term = matches[1]!;
|
||||
|
||||
const start = content.indexOf(matches[0]);
|
||||
const end = start + matches[0].length;
|
||||
|
||||
const startIndex = start > 0 ? 1 : 0;
|
||||
const contentIndex = startIndex + 1;
|
||||
const endIndex = contentIndex + 1;
|
||||
|
||||
const textNodes = node.splitText(start, start + 2, end - 2, end);
|
||||
|
||||
const termNode = $createTermNode(term);
|
||||
textNodes[startIndex]!.insertBefore(termNode);
|
||||
|
||||
const iconNode = $createTermIconNode(term);
|
||||
termNode.append(iconNode);
|
||||
const startMarkerNode = $createTermMarkerNode();
|
||||
startMarkerNode.append(textNodes[startIndex]!);
|
||||
const endMarkerNode = $createTermMarkerNode();
|
||||
endMarkerNode.append(textNodes[endIndex]!);
|
||||
|
||||
termNode.append(startMarkerNode, textNodes[contentIndex]!, endMarkerNode);
|
||||
|
||||
editor.dispatchCommand(SELECTION_CHANGE_COMMAND, undefined);
|
||||
}),
|
||||
editor.registerNodeTransform(TermMarkerNode, (node) => {
|
||||
const termNode = node.getParent();
|
||||
if ($isTermNode(termNode)) return;
|
||||
|
||||
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
|
||||
node.remove();
|
||||
}),
|
||||
editor.registerNodeTransform(TermIconNode, (node) => {
|
||||
const termNode = node.getParent();
|
||||
if ($isTermNode(termNode)) return;
|
||||
|
||||
node.remove();
|
||||
}),
|
||||
editor.registerNodeTransform(TermNode, (node) => {
|
||||
const children = node.getChildren();
|
||||
const content = node.getTextContent();
|
||||
const term = node.getTerm();
|
||||
|
||||
const iconNode = children.at(0);
|
||||
const startMarkerNode = children.at(1);
|
||||
const endMarkerNode = children.at(-1);
|
||||
|
||||
if ($isTermIconNode(iconNode) &&
|
||||
$isTermMarkerNode(startMarkerNode) &&
|
||||
$isTermMarkerNode(endMarkerNode) &&
|
||||
iconNode.getTerm() === term &&
|
||||
startMarkerNode.getTextContent() === "[[" &&
|
||||
endMarkerNode.getTextContent() === "]]" &&
|
||||
content === `[[${term}]]`
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
node.getChildren().reverse().forEach((child) => node.insertAfter(child));
|
||||
node.remove();
|
||||
}),
|
||||
));
|
||||
|
||||
return null;
|
||||
}
|
263
src/editor/serialized_editor_content.ts
Normal file
263
src/editor/serialized_editor_content.ts
Normal file
|
@ -0,0 +1,263 @@
|
|||
// TODO: This needs to be removed, but some exported types need to be moved first
|
||||
import { $getRoot, $isLineBreakNode, $isParagraphNode, $isTextNode, type LexicalNode } from "lexical";
|
||||
import { $isHeaderNode } from "./nodes/header_node";
|
||||
import { $isTaskNode } from "./nodes/task_node";
|
||||
import { $isFormattedTextNode } from "./nodes/formatted_text";
|
||||
import { $isLinkNode } from "./nodes/link_node";
|
||||
import { $isTermNode } from "./nodes/term_node";
|
||||
|
||||
export type SerializedEditorContent = {
|
||||
content: SerializedBlockNode[];
|
||||
}
|
||||
|
||||
type SerializedTextContainingNode =
|
||||
SerializedTextNode
|
||||
| SerializedFormattedTextNode
|
||||
| SerializedLinebreakNode
|
||||
| SerializedLinkNode
|
||||
| SerializedTermNode;
|
||||
type SerializedBlockNode =
|
||||
SerializedHeaderNode
|
||||
| SerializedParagraphNode
|
||||
| SerializedTaskNode;
|
||||
|
||||
type SerializedTextNode = {
|
||||
type: "text";
|
||||
content: string;
|
||||
}
|
||||
|
||||
type SerializedLinebreakNode = {
|
||||
type: "linebreak";
|
||||
}
|
||||
|
||||
type SerializedParagraphNode = {
|
||||
type: "paragraph";
|
||||
content: SerializedTextContainingNode[];
|
||||
}
|
||||
|
||||
type SerializedHeaderNode = {
|
||||
type: "header";
|
||||
level: number;
|
||||
content: SerializedTextContainingNode[];
|
||||
}
|
||||
|
||||
export type TextFormat = "bold" | "italic" | "underline" | "strikethrough" | "code";
|
||||
export const textFormats: Record<TextFormat, string> = {
|
||||
bold: "**",
|
||||
italic: "//",
|
||||
underline: "__",
|
||||
strikethrough: "~~",
|
||||
code: "`",
|
||||
};
|
||||
type SerializedFormattedTextNode = {
|
||||
type: "formatted_text";
|
||||
format: TextFormat;
|
||||
content: SerializedTextContainingNode[];
|
||||
}
|
||||
|
||||
type SerializedLinkNode = {
|
||||
type: "link";
|
||||
label: SerializedTextContainingNode[];
|
||||
url: string;
|
||||
}
|
||||
|
||||
type SerializedTermNode = {
|
||||
type: "term";
|
||||
term: string;
|
||||
}
|
||||
|
||||
|
||||
export type TaskLabel = "TODO" | "DOING" | "DONE" | "IDEA" | "DEADLINE";
|
||||
type SerializedTaskNode = {
|
||||
type: "task";
|
||||
label: TaskLabel;
|
||||
content: SerializedTextContainingNode[];
|
||||
|
||||
}
|
||||
|
||||
export function $serializeEditorContent(): SerializedEditorContent {
|
||||
return {
|
||||
content: $getRoot().getChildren().map(serializeBlockNode),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeBlockNode(node: LexicalNode): SerializedBlockNode {
|
||||
switch (true) {
|
||||
case $isParagraphNode(node): return {
|
||||
type: "paragraph",
|
||||
content: node.getChildren().map(serializeTextContainingNode),
|
||||
}
|
||||
case $isHeaderNode(node): return {
|
||||
type: "header",
|
||||
level: node.getLevel(),
|
||||
// Slice(1) to remove the marker node
|
||||
content: node.getChildren().slice(1).map(serializeTextContainingNode),
|
||||
}
|
||||
case $isTaskNode(node): return {
|
||||
type: "task",
|
||||
label: node.getTaskType().label,
|
||||
// Slice(2) to remove the marker nodes
|
||||
content: node.getChildren().slice(2).map(serializeTextContainingNode),
|
||||
}
|
||||
default: throw new Error(`Unknown block node type: ${node.getType()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function serializeTextContainingNode(node: LexicalNode): SerializedTextContainingNode {
|
||||
switch (true) {
|
||||
case $isTextNode(node): return {
|
||||
type: "text",
|
||||
content: node.getTextContent(),
|
||||
}
|
||||
case $isFormattedTextNode(node): return {
|
||||
type: "formatted_text",
|
||||
format: node.getStyle(),
|
||||
content: node.getChildren().slice(1, -1).map(serializeTextContainingNode),
|
||||
}
|
||||
case $isLineBreakNode(node): return {
|
||||
type: "linebreak",
|
||||
}
|
||||
case $isLinkNode(node): return {
|
||||
type: "link",
|
||||
label: node.getChildren().slice(1, -5).map(serializeTextContainingNode),
|
||||
url: node.getUrl(),
|
||||
|
||||
}
|
||||
case $isTermNode(node): return {
|
||||
type: "term",
|
||||
term: node.getTerm(),
|
||||
}
|
||||
default: throw new Error(`Unknown text containing node type: ${node.getType()}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function deserializeEditorContent(serialized: SerializedEditorContent) {
|
||||
return {
|
||||
root: {
|
||||
type: "root",
|
||||
children: serialized.content.map(deserializeBlockNode),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function deserializeBlockNode(serialized: SerializedBlockNode) {
|
||||
switch (serialized.type) {
|
||||
case "header": return {
|
||||
type: "header",
|
||||
level: serialized.level,
|
||||
children: [
|
||||
{
|
||||
type: "header-marker",
|
||||
children: [createTextNode(`${"#".repeat(serialized.level)} `)]
|
||||
},
|
||||
...serialized.content.map(deserializeTextContainingNode)
|
||||
],
|
||||
}
|
||||
case "paragraph": return {
|
||||
type: "paragraph",
|
||||
children: [
|
||||
...serialized.content.map(deserializeTextContainingNode)
|
||||
],
|
||||
}
|
||||
case "task": return {
|
||||
type: "task",
|
||||
taskType: serialized.label,
|
||||
children: [
|
||||
{
|
||||
type: "task-icon",
|
||||
taskType: serialized.label,
|
||||
},
|
||||
{
|
||||
type: "task-marker",
|
||||
children: [createTextNode(`${serialized.label} `)]
|
||||
},
|
||||
...serialized.content.map(deserializeTextContainingNode)
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function deserializeTextContainingNode(serialized: SerializedTextContainingNode): any {
|
||||
switch (serialized.type) {
|
||||
case "text": return createTextNode(serialized.content);
|
||||
case "formatted_text": return {
|
||||
type: "formatted-text",
|
||||
style: serialized.format,
|
||||
children: [
|
||||
{
|
||||
type: "formatted-text-marker",
|
||||
children: [createTextNode(textFormats[serialized.format])]
|
||||
},
|
||||
...serialized.content.map(deserializeTextContainingNode),
|
||||
{
|
||||
type: "formatted-text-marker",
|
||||
children: [createTextNode(textFormats[serialized.format])]
|
||||
},
|
||||
],
|
||||
}
|
||||
case "link": return {
|
||||
type: "link",
|
||||
children: [
|
||||
{
|
||||
type: "link-marker",
|
||||
children: [createTextNode("[")],
|
||||
},
|
||||
...serialized.label.map(deserializeTextContainingNode),
|
||||
{
|
||||
type: "link-marker",
|
||||
children: [createTextNode("]")],
|
||||
},
|
||||
{
|
||||
type: "link-marker",
|
||||
children: [createTextNode("(")],
|
||||
},
|
||||
{
|
||||
type: "link-url",
|
||||
children: [createTextNode(serialized.url)],
|
||||
},
|
||||
{
|
||||
type: "link-marker",
|
||||
children: [createTextNode(")")],
|
||||
},
|
||||
{
|
||||
type: "link-icon",
|
||||
url: serialized.url,
|
||||
}
|
||||
],
|
||||
};
|
||||
case "term": return {
|
||||
type: "term",
|
||||
term: serialized.term,
|
||||
children: [
|
||||
{
|
||||
type: "term-icon",
|
||||
term: serialized.term,
|
||||
},
|
||||
{
|
||||
type: "term-marker",
|
||||
children: [createTextNode("[[")]
|
||||
},
|
||||
createTextNode(serialized.term),
|
||||
{
|
||||
type: "term-marker",
|
||||
children: [createTextNode("]]")]
|
||||
},
|
||||
|
||||
],
|
||||
}
|
||||
case "linebreak": return { type: "linebreak" };
|
||||
}
|
||||
}
|
||||
|
||||
function createTextNode(content: string) {
|
||||
return {
|
||||
type: "text",
|
||||
text: content,
|
||||
|
||||
version: 1,
|
||||
detail: 0,
|
||||
format: 0,
|
||||
mode: "normal",
|
||||
style: "",
|
||||
}
|
||||
}
|
8
src/global.d.ts
vendored
Normal file
8
src/global.d.ts
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
import 'react';
|
||||
|
||||
declare module 'react' {
|
||||
// Allow CSS variables inside inline styles
|
||||
interface CSSProperties {
|
||||
[key: `--${string}`]: string | number
|
||||
}
|
||||
}
|
65
src/hooks/use-metadata.tsx
Normal file
65
src/hooks/use-metadata.tsx
Normal file
|
@ -0,0 +1,65 @@
|
|||
import React, { useContext } from "react";
|
||||
import * as Y from "yjs";
|
||||
import { useYDoc } from "~/hooks/use-ydoc";
|
||||
import type { CollectionId, CollectionMetadata, CollectionsMetadata, NoteId, NoteMetadata, NotesMetadata } from "~/lib/metadata";
|
||||
import { useObserve } from "~/hooks/use-observe";
|
||||
|
||||
const MetadataContext = React.createContext<Y.Doc | null>(null);
|
||||
|
||||
export function MetadataProvider(props: { children: React.ReactNode }) {
|
||||
const { ydoc } = useYDoc("metadata");
|
||||
|
||||
return (
|
||||
<MetadataContext.Provider value={ydoc} >
|
||||
{props.children}
|
||||
</MetadataContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCollections(): CollectionsMetadata {
|
||||
const doc = useContext(MetadataContext)
|
||||
|
||||
if (!doc) {
|
||||
throw new Error("useCollections must be used within a MetadataProvider")
|
||||
}
|
||||
|
||||
const collections = doc.getArray("collections")
|
||||
useObserve(collections, "deep")
|
||||
|
||||
return collections as any as CollectionsMetadata
|
||||
}
|
||||
|
||||
export function useCollection(id: CollectionId | undefined): CollectionMetadata | undefined {
|
||||
const collections = useCollections()
|
||||
const collection = collections.toArray().find(it => it.get("id") == id)
|
||||
|
||||
return collection
|
||||
}
|
||||
|
||||
|
||||
export function useNotesMetadata(): NotesMetadata {
|
||||
const doc = useContext(MetadataContext)
|
||||
|
||||
if (!doc) {
|
||||
throw new Error("useNotes must be used within a MetadataProvider")
|
||||
}
|
||||
|
||||
const notes = doc.getArray("notes")
|
||||
useObserve(notes, "deep")
|
||||
|
||||
return notes as any as NotesMetadata
|
||||
}
|
||||
|
||||
export function useCollectionNotesMetadata(collectionId: CollectionId | ""): NoteMetadata[] {
|
||||
const notes = useNotesMetadata()
|
||||
|
||||
return notes.toArray().filter(it => it.get("collectionId") == collectionId)
|
||||
|
||||
}
|
||||
|
||||
export function useNoteMetadata(id: NoteId): NoteMetadata | undefined {
|
||||
const notes = useNotesMetadata()
|
||||
const note = notes.toArray().find(it => it.get("id") == id)
|
||||
|
||||
return note
|
||||
}
|
81
src/hooks/use-note.tsx
Normal file
81
src/hooks/use-note.tsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import React from "react";
|
||||
import * as Y from "yjs";
|
||||
import type { NoteId } from "~/lib/metadata";
|
||||
import { useYDoc } from "./use-ydoc";
|
||||
import type { WebsocketProvider } from "y-websocket";
|
||||
import type { IndexeddbPersistence } from "y-indexeddb";
|
||||
|
||||
const NoteContext = React.createContext<{
|
||||
doc: Y.Doc,
|
||||
providers: {
|
||||
indexeddb: IndexeddbPersistence,
|
||||
websocket: WebsocketProvider,
|
||||
}
|
||||
} | null>(null);
|
||||
|
||||
export function NoteProvider(props: { children: React.ReactNode, id: NoteId }) {
|
||||
const { ydoc, indexeddbProvider, websocketProvider } = useYDoc(`note-${props.id}`);
|
||||
|
||||
// If we don't have the provider yet, delay rendering until we do.
|
||||
if (!indexeddbProvider || !websocketProvider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<NoteContext.Provider value={{
|
||||
doc: ydoc,
|
||||
providers: {
|
||||
indexeddb: indexeddbProvider,
|
||||
websocket: websocketProvider,
|
||||
}
|
||||
}}>
|
||||
{props.children}
|
||||
</NoteContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useNoteDoc() {
|
||||
const ctx = React.useContext(NoteContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useNoteDoc must be used within a NoteProvider")
|
||||
}
|
||||
return ctx.doc;
|
||||
}
|
||||
|
||||
export function useNoteMap(name: string) {
|
||||
const ctx = React.useContext(NoteContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useNoteMap must be used within a NoteProvider")
|
||||
}
|
||||
const map = ctx.doc.getMap(name);
|
||||
return map;
|
||||
}
|
||||
|
||||
export function useNoteArray(name: string) {
|
||||
const ctx = React.useContext(NoteContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useNoteArray must be used within a NoteProvider")
|
||||
}
|
||||
const array = ctx.doc.getArray(name);
|
||||
return array;
|
||||
}
|
||||
|
||||
export function useNoteProviders() {
|
||||
const ctx = React.useContext(NoteContext);
|
||||
|
||||
if (!ctx) {
|
||||
throw new Error("useNoteProviders must be used within a NoteProvider")
|
||||
}
|
||||
|
||||
return ctx.providers;
|
||||
}
|
||||
|
||||
export function useNoteAwareness() {
|
||||
const ctx = React.useContext(NoteContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useNoteAwareness must be used within a NoteProvider")
|
||||
}
|
||||
|
||||
const awareness = ctx.providers.websocket.awareness;
|
||||
return awareness;
|
||||
}
|
22
src/hooks/use-observe.ts
Normal file
22
src/hooks/use-observe.ts
Normal file
|
@ -0,0 +1,22 @@
|
|||
import { useEffect } from "react";
|
||||
import * as Y from "yjs"
|
||||
import { useRedraw } from "~/hooks/use-redraw"
|
||||
|
||||
export function useObserve(object: Y.AbstractType<any>, kind: "deep" | "normal" = "normal") {
|
||||
const redraw = useRedraw();
|
||||
|
||||
useEffect(() => {
|
||||
if (kind === "deep") {
|
||||
object.observeDeep(redraw);
|
||||
} else if (kind === "normal") {
|
||||
object.observe(redraw);
|
||||
}
|
||||
return () => {
|
||||
if (kind === "deep") {
|
||||
object.unobserveDeep(redraw);
|
||||
} else if (kind === "normal") {
|
||||
object.unobserve(redraw);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}
|
7
src/hooks/use-redraw.ts
Normal file
7
src/hooks/use-redraw.ts
Normal file
|
@ -0,0 +1,7 @@
|
|||
import { useState } from "react";
|
||||
|
||||
export function useRedraw() {
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
return () => setTick((tick) => tick + 1);
|
||||
}
|
47
src/hooks/use-theme.tsx
Normal file
47
src/hooks/use-theme.tsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
import React, { useCallback, useContext, useLayoutEffect, useState } from "react";
|
||||
|
||||
type Theme = "system" | "light" | "dark";
|
||||
|
||||
const ThemeContex = React.createContext<{ theme: Theme, effectiveTheme: "light" | "dark", setTheme: (theme: Theme) => void }>({
|
||||
theme: "system",
|
||||
effectiveTheme: "light",
|
||||
setTheme: () => { }
|
||||
});
|
||||
|
||||
export function ThemeProvider(props: { children: React.ReactNode }) {
|
||||
const [localTheme, setLocalTheme] = useState<Theme>(() => localStorage.getItem("theme") as Theme || "system");
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
|
||||
const effectiveTheme = localTheme === "system" ? systemTheme : localTheme;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", effectiveTheme)
|
||||
}, [effectiveTheme])
|
||||
|
||||
const setTheme = useCallback((newTheme: Theme) => {
|
||||
setLocalTheme(newTheme);
|
||||
localStorage.setItem("theme", newTheme);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ThemeContex.Provider value={{
|
||||
theme: localTheme,
|
||||
effectiveTheme,
|
||||
setTheme
|
||||
}}>
|
||||
{props.children}
|
||||
</ThemeContex.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const { theme, setTheme } = useContext(ThemeContex);
|
||||
|
||||
return [theme, setTheme] as const;
|
||||
}
|
||||
|
||||
export function useEffectiveTheme() {
|
||||
const { effectiveTheme } = useContext(ThemeContex);
|
||||
|
||||
return effectiveTheme;
|
||||
}
|
33
src/hooks/use-ydoc.ts
Normal file
33
src/hooks/use-ydoc.ts
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import * as Y from 'yjs';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb'
|
||||
import { WebsocketProvider } from 'y-websocket';
|
||||
|
||||
export function useYDoc(name: string) {
|
||||
const [ydoc] = useState<Y.Doc>(() => new Y.Doc());
|
||||
const [indexeddbProvider, setIndexeddbProvider] = useState<IndexeddbPersistence | null>(null);
|
||||
const [websocketProvider, setWebsocketProvider] = useState<WebsocketProvider | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ydoc) {
|
||||
return
|
||||
}
|
||||
|
||||
const indexeddbProvider = new IndexeddbPersistence(name, ydoc);
|
||||
setIndexeddbProvider(indexeddbProvider);
|
||||
|
||||
const websocketProvider = new WebsocketProvider(`ws://localhost:1234`, name, ydoc, {
|
||||
// TODO: For now we don't connect to a server yet, this obviously needs to change.
|
||||
connect: false,
|
||||
});
|
||||
setWebsocketProvider(websocketProvider);
|
||||
|
||||
return () => {
|
||||
indexeddbProvider.destroy();
|
||||
websocketProvider.destroy();
|
||||
ydoc.destroy();
|
||||
};
|
||||
}, [ydoc]);
|
||||
|
||||
return { ydoc, indexeddbProvider, websocketProvider };
|
||||
}
|
26
src/lib/color.ts
Normal file
26
src/lib/color.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
export type ColorName = keyof typeof colors;
|
||||
export type Color = typeof colors[ColorName];
|
||||
|
||||
// Tailwind colors
|
||||
// Base: 400
|
||||
// Hover: 300
|
||||
export const colors = {
|
||||
red: { label: "Red", base: "#f87171", hover: "#fca5a5" },
|
||||
orange: { label: "Orange", base: "#fb923c", hover: "#fdba74" },
|
||||
amber: { label: "Amber", base: "#fbbf24", hover: "#fcd34d" },
|
||||
yellow: { label: "Yellow", base: "#facc15", hover: "#fde047" },
|
||||
lime: { label: "Lime", base: "#a3e635", hover: "#bef264" },
|
||||
green: { label: "Green", base: "#4ade80", hover: "#86efac" },
|
||||
emerald: { label: "Emerald", base: "#34d399", hover: "#6ee7b7" },
|
||||
teal: { label: "Teal", base: "#2dd4bf", hover: "#5eead4" },
|
||||
cyan: { label: "Cyan", base: "#22d3ee", hover: "#67e8f9" },
|
||||
sky: { label: "Sky", base: "#38bdf8", hover: "#7dd3fc" },
|
||||
blue: { label: "Blue", base: "#60a5fa", hover: "#93c5fd" },
|
||||
indigo: { label: "Indigo", base: "#818cf8", hover: "#a5b4fc" },
|
||||
violet: { label: "Violet", base: "#a78bfa", hover: "#c4b5fd" },
|
||||
purple: { label: "Purple", base: "#c084fc", hover: "#d8b4fe" },
|
||||
fuchsia: { label: "Fuchsia", base: "#e879f9", hover: "#f0abfc" },
|
||||
pink: { label: "Pink", base: "#f472b6", hover: "#f9a8d4" },
|
||||
rose: { label: "Rose", base: "#fb7185", hover: "#fda4af" },
|
||||
white: { label: "White", base: "#a3a3a3", hover: "#d4d4d4" },
|
||||
};
|
12
src/lib/icon.ts
Normal file
12
src/lib/icon.ts
Normal file
|
@ -0,0 +1,12 @@
|
|||
import * as mdi_icons from "@mdi/js";
|
||||
|
||||
export type IconName = string; //keyof typeof icons;
|
||||
export type Icon = typeof icons[IconName];
|
||||
|
||||
export const icons = Object.fromEntries(Object.entries(mdi_icons).map(([key, value]) => ([
|
||||
key.replace(/([A-Z])/g, "-$1").replace(/^mdi-/, "").toLowerCase(),
|
||||
{
|
||||
path: value,
|
||||
name: key.replace("mdi", "").replace(/([A-Z])/g, " $1"),
|
||||
}
|
||||
])));
|
99
src/lib/metadata.ts
Normal file
99
src/lib/metadata.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
import * as Y from "yjs"
|
||||
|
||||
import type { ColorName } from "~/lib/color"
|
||||
import type { IconName } from "~/lib/icon"
|
||||
import type { YArray, YMap } from "~/lib/yjs"
|
||||
import type { PropertyType } from "~/lib/property"
|
||||
|
||||
export type NoteId = string
|
||||
export type CollectionId = string
|
||||
export type PropertyId = string
|
||||
|
||||
export type NotesMetadata = YArray<NoteMetadata>
|
||||
|
||||
export type NoteMetadata = YMap<{
|
||||
id: NoteId
|
||||
collectionId: CollectionId | ""
|
||||
title: string
|
||||
icon: IconName | ""
|
||||
primaryColor: ColorName | ""
|
||||
secondaryColor: ColorName | ""
|
||||
pinned: boolean
|
||||
properties: YArray<NoteProperty>
|
||||
type: "text" | "canvas"
|
||||
}>
|
||||
|
||||
export type NoteProperty = YMap<{
|
||||
propertyId: PropertyId
|
||||
value: string
|
||||
}>
|
||||
|
||||
export type CollectionsMetadata = YArray<CollectionMetadata>
|
||||
|
||||
export type CollectionMetadata = YMap<{
|
||||
id: CollectionId
|
||||
name: string
|
||||
color: ColorName
|
||||
icon: IconName | ""
|
||||
properties?: YArray<CollectionProperty>
|
||||
}>
|
||||
|
||||
export type CollectionProperty = YMap<{
|
||||
id: PropertyId
|
||||
name: string
|
||||
type: PropertyType
|
||||
pinned: boolean
|
||||
}>
|
||||
|
||||
export function createCollection(md: CollectionsMetadata, data: {
|
||||
name: string,
|
||||
color: ColorName,
|
||||
icon: IconName,
|
||||
}) {
|
||||
const collection = new Y.Map() as any as CollectionMetadata;
|
||||
collection.set("id", randomUUID());
|
||||
collection.set("name", data.name);
|
||||
collection.set("color", data.color);
|
||||
collection.set("icon", data.icon);
|
||||
collection.set("properties", new Y.Array() as any);
|
||||
|
||||
md.push([collection])
|
||||
return collection;
|
||||
}
|
||||
|
||||
export function deleteCollection(md: CollectionsMetadata, id: CollectionId) {
|
||||
const index = md.toArray().findIndex(it => it.get("id") == id);
|
||||
if (index == -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
md.delete(index, 1)
|
||||
}
|
||||
|
||||
export function createNote(md: NotesMetadata, data: {
|
||||
name: string,
|
||||
icon: IconName | undefined,
|
||||
collectionId: CollectionId | undefined,
|
||||
primaryColor: ColorName | undefined,
|
||||
secondaryColor: ColorName | undefined,
|
||||
type: "text" | "canvas"
|
||||
}) {
|
||||
const note = new Y.Map() as any as NoteMetadata;
|
||||
note.set("id", randomUUID());
|
||||
note.set("title", data.name);
|
||||
note.set("icon", data.icon ?? "");
|
||||
note.set("collectionId", data.collectionId ?? "");
|
||||
note.set("primaryColor", data.primaryColor ?? "");
|
||||
note.set("secondaryColor", data.secondaryColor ?? "");
|
||||
note.set("pinned", false);
|
||||
note.set("properties", new Y.Array() as any);
|
||||
note.set("type", data.type);
|
||||
|
||||
md.push([note])
|
||||
|
||||
return note;
|
||||
}
|
||||
|
||||
export function randomUUID() {
|
||||
return crypto.randomUUID()
|
||||
}
|
42
src/lib/property.ts
Normal file
42
src/lib/property.ts
Normal file
|
@ -0,0 +1,42 @@
|
|||
import { randomUUID, type CollectionProperty, type NoteProperty, type PropertyId } from "~/lib/metadata";
|
||||
import type { YArray } from "~/lib/yjs";
|
||||
import * as Y from "yjs";
|
||||
|
||||
export const propertyTypes = ["shortText"] as const;
|
||||
|
||||
export type PropertyType = typeof propertyTypes[number];
|
||||
|
||||
export const properties: { [P in PropertyType]: string } = {
|
||||
"shortText": "Short text",
|
||||
};
|
||||
|
||||
export function createCollectionProperty(md: YArray<CollectionProperty>) {
|
||||
const property = new Y.Map() as any as CollectionProperty;
|
||||
property.set("id", randomUUID());
|
||||
property.set("name", "");
|
||||
property.set("type", "shortText");
|
||||
property.set("pinned", false);
|
||||
|
||||
md.push([property]);
|
||||
return property;
|
||||
}
|
||||
|
||||
export function deleteCollectionProperty(md: YArray<CollectionProperty>, id: PropertyId) {
|
||||
const index = md.toArray().findIndex((prop) => prop.get("id") === id);
|
||||
if (index === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
md.delete(index, 1);
|
||||
}
|
||||
|
||||
export function createNoteProperty(md: YArray<NoteProperty>, data: {
|
||||
propertyId: PropertyId;
|
||||
}) {
|
||||
const property = new Y.Map() as any as NoteProperty;
|
||||
property.set("propertyId", data.propertyId);
|
||||
property.set("value", "");
|
||||
|
||||
md.push([property]);
|
||||
return property;
|
||||
}
|
48
src/lib/yjs.ts
Normal file
48
src/lib/yjs.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
import * as Y from 'yjs'
|
||||
|
||||
export type YValue = object | boolean | string | number | Uint8Array | YMap<any> | YArray<any> | Y.Text
|
||||
|
||||
type YValueToJSON<T> =
|
||||
T extends YMap<any> ? YMapToJSON<T>
|
||||
: T extends YArray<any> ? YArrayToJSON<T>
|
||||
: T extends Y.Text ? string
|
||||
: T
|
||||
|
||||
type YMapToJSON<T> = T extends YMap<infer X>
|
||||
? { [KEY in keyof X]: YValueToJSON<X[KEY]> }
|
||||
: never;
|
||||
|
||||
type YArrayToJSON<T> = T extends YArray<infer X> ? YValueToJSON<X>[] : never;
|
||||
|
||||
export type YMap<T extends { [key: string]: YValue }> = {
|
||||
set: <X extends keyof T>(key: X, value: T[X]) => void
|
||||
get: <X extends keyof T>(key: X) => T[X]
|
||||
delete: <X extends keyof T>(key: X) => void
|
||||
has: <X extends keyof T>(key: X) => boolean
|
||||
clear: () => void
|
||||
toJSON: () => YMapToJSON<YMap<T>>
|
||||
forEach: (callback: <X extends keyof T>(value: T[X], key: X, map: YMap<T>) => void) => void
|
||||
entries: () => Iterator<[string, YValue]>
|
||||
values: () => Iterator<YValue>
|
||||
keys: () => Iterator<keyof T>
|
||||
clone: () => YMap<T>
|
||||
|
||||
size: number
|
||||
}
|
||||
|
||||
export type YArray<T extends YValue> = {
|
||||
insert: (index: number, content: T[]) => void
|
||||
delete: (index: number, length: number) => void
|
||||
push: (values: T[]) => void
|
||||
unshift: (content: T[]) => void
|
||||
get: (index: number) => T | undefined
|
||||
slice: (start: number, end?: number) => T[]
|
||||
toArray: () => T[]
|
||||
toJSON: () => YArrayToJSON<YArray<T>>
|
||||
forEach: (callback: (value: T, index: number, yarray: YArray<T>) => void) => void
|
||||
map: <X>(callback: (value: T, index: number, yarray: YArray<T>) => X) => X[]
|
||||
clone: () => YArray<T>
|
||||
|
||||
length: number
|
||||
}
|
||||
|
18
src/main.tsx
18
src/main.tsx
|
@ -2,28 +2,14 @@ import { StrictMode } from 'react'
|
|||
import ReactDOM from 'react-dom/client'
|
||||
import {
|
||||
RouterProvider,
|
||||
createRootRoute,
|
||||
createRoute,
|
||||
createRouter,
|
||||
} from '@tanstack/react-router'
|
||||
|
||||
import '~/styles.css'
|
||||
import reportWebVitals from '~/reportWebVitals.ts'
|
||||
|
||||
import { RootLayout } from '~/layouts/RootLayout'
|
||||
import { RootPage } from '~/pages/Root'
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
component: RootLayout,
|
||||
})
|
||||
|
||||
const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: RootPage,
|
||||
})
|
||||
|
||||
const routeTree = rootRoute.addChildren([indexRoute])
|
||||
// Import the generated route tree
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
|
|
369
src/routeTree.gen.ts
Normal file
369
src/routeTree.gen.ts
Normal file
|
@ -0,0 +1,369 @@
|
|||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
// Import Routes
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as AppImport } from './routes/app'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
import { Route as AppIndexImport } from './routes/app/index'
|
||||
import { Route as AppTodoImport } from './routes/app/todo'
|
||||
import { Route as AppSearchImport } from './routes/app/search'
|
||||
import { Route as AppInboxImport } from './routes/app/inbox'
|
||||
import { Route as AppGraphImport } from './routes/app/graph'
|
||||
import { Route as AppCalendarImport } from './routes/app/calendar'
|
||||
import { Route as AppTermTermImport } from './routes/app/term.$term'
|
||||
import { Route as AppNoteIdImport } from './routes/app/note.$id'
|
||||
import { Route as AppCollectionIdImport } from './routes/app/collection.$id'
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const AppRoute = AppImport.update({
|
||||
id: '/app',
|
||||
path: '/app',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const IndexRoute = IndexImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const AppIndexRoute = AppIndexImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
|
||||
const AppTodoRoute = AppTodoImport.update({
|
||||
id: '/todo',
|
||||
path: '/todo',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
|
||||
const AppSearchRoute = AppSearchImport.update({
|
||||
id: '/search',
|
||||
path: '/search',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
|
||||
const AppInboxRoute = AppInboxImport.update({
|
||||
id: '/inbox',
|
||||
path: '/inbox',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
|
||||
const AppGraphRoute = AppGraphImport.update({
|
||||
id: '/graph',
|
||||
path: '/graph',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
|
||||
const AppCalendarRoute = AppCalendarImport.update({
|
||||
id: '/calendar',
|
||||
path: '/calendar',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
|
||||
const AppTermTermRoute = AppTermTermImport.update({
|
||||
id: '/term/$term',
|
||||
path: '/term/$term',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
|
||||
const AppNoteIdRoute = AppNoteIdImport.update({
|
||||
id: '/note/$id',
|
||||
path: '/note/$id',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
|
||||
const AppCollectionIdRoute = AppCollectionIdImport.update({
|
||||
id: '/collection/$id',
|
||||
path: '/collection/$id',
|
||||
getParentRoute: () => AppRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/app': {
|
||||
id: '/app'
|
||||
path: '/app'
|
||||
fullPath: '/app'
|
||||
preLoaderRoute: typeof AppImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/app/calendar': {
|
||||
id: '/app/calendar'
|
||||
path: '/calendar'
|
||||
fullPath: '/app/calendar'
|
||||
preLoaderRoute: typeof AppCalendarImport
|
||||
parentRoute: typeof AppImport
|
||||
}
|
||||
'/app/graph': {
|
||||
id: '/app/graph'
|
||||
path: '/graph'
|
||||
fullPath: '/app/graph'
|
||||
preLoaderRoute: typeof AppGraphImport
|
||||
parentRoute: typeof AppImport
|
||||
}
|
||||
'/app/inbox': {
|
||||
id: '/app/inbox'
|
||||
path: '/inbox'
|
||||
fullPath: '/app/inbox'
|
||||
preLoaderRoute: typeof AppInboxImport
|
||||
parentRoute: typeof AppImport
|
||||
}
|
||||
'/app/search': {
|
||||
id: '/app/search'
|
||||
path: '/search'
|
||||
fullPath: '/app/search'
|
||||
preLoaderRoute: typeof AppSearchImport
|
||||
parentRoute: typeof AppImport
|
||||
}
|
||||
'/app/todo': {
|
||||
id: '/app/todo'
|
||||
path: '/todo'
|
||||
fullPath: '/app/todo'
|
||||
preLoaderRoute: typeof AppTodoImport
|
||||
parentRoute: typeof AppImport
|
||||
}
|
||||
'/app/': {
|
||||
id: '/app/'
|
||||
path: '/'
|
||||
fullPath: '/app/'
|
||||
preLoaderRoute: typeof AppIndexImport
|
||||
parentRoute: typeof AppImport
|
||||
}
|
||||
'/app/collection/$id': {
|
||||
id: '/app/collection/$id'
|
||||
path: '/collection/$id'
|
||||
fullPath: '/app/collection/$id'
|
||||
preLoaderRoute: typeof AppCollectionIdImport
|
||||
parentRoute: typeof AppImport
|
||||
}
|
||||
'/app/note/$id': {
|
||||
id: '/app/note/$id'
|
||||
path: '/note/$id'
|
||||
fullPath: '/app/note/$id'
|
||||
preLoaderRoute: typeof AppNoteIdImport
|
||||
parentRoute: typeof AppImport
|
||||
}
|
||||
'/app/term/$term': {
|
||||
id: '/app/term/$term'
|
||||
path: '/term/$term'
|
||||
fullPath: '/app/term/$term'
|
||||
preLoaderRoute: typeof AppTermTermImport
|
||||
parentRoute: typeof AppImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
interface AppRouteChildren {
|
||||
AppCalendarRoute: typeof AppCalendarRoute
|
||||
AppGraphRoute: typeof AppGraphRoute
|
||||
AppInboxRoute: typeof AppInboxRoute
|
||||
AppSearchRoute: typeof AppSearchRoute
|
||||
AppTodoRoute: typeof AppTodoRoute
|
||||
AppIndexRoute: typeof AppIndexRoute
|
||||
AppCollectionIdRoute: typeof AppCollectionIdRoute
|
||||
AppNoteIdRoute: typeof AppNoteIdRoute
|
||||
AppTermTermRoute: typeof AppTermTermRoute
|
||||
}
|
||||
|
||||
const AppRouteChildren: AppRouteChildren = {
|
||||
AppCalendarRoute: AppCalendarRoute,
|
||||
AppGraphRoute: AppGraphRoute,
|
||||
AppInboxRoute: AppInboxRoute,
|
||||
AppSearchRoute: AppSearchRoute,
|
||||
AppTodoRoute: AppTodoRoute,
|
||||
AppIndexRoute: AppIndexRoute,
|
||||
AppCollectionIdRoute: AppCollectionIdRoute,
|
||||
AppNoteIdRoute: AppNoteIdRoute,
|
||||
AppTermTermRoute: AppTermTermRoute,
|
||||
}
|
||||
|
||||
const AppRouteWithChildren = AppRoute._addFileChildren(AppRouteChildren)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/app': typeof AppRouteWithChildren
|
||||
'/app/calendar': typeof AppCalendarRoute
|
||||
'/app/graph': typeof AppGraphRoute
|
||||
'/app/inbox': typeof AppInboxRoute
|
||||
'/app/search': typeof AppSearchRoute
|
||||
'/app/todo': typeof AppTodoRoute
|
||||
'/app/': typeof AppIndexRoute
|
||||
'/app/collection/$id': typeof AppCollectionIdRoute
|
||||
'/app/note/$id': typeof AppNoteIdRoute
|
||||
'/app/term/$term': typeof AppTermTermRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/app/calendar': typeof AppCalendarRoute
|
||||
'/app/graph': typeof AppGraphRoute
|
||||
'/app/inbox': typeof AppInboxRoute
|
||||
'/app/search': typeof AppSearchRoute
|
||||
'/app/todo': typeof AppTodoRoute
|
||||
'/app': typeof AppIndexRoute
|
||||
'/app/collection/$id': typeof AppCollectionIdRoute
|
||||
'/app/note/$id': typeof AppNoteIdRoute
|
||||
'/app/term/$term': typeof AppTermTermRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
'/': typeof IndexRoute
|
||||
'/app': typeof AppRouteWithChildren
|
||||
'/app/calendar': typeof AppCalendarRoute
|
||||
'/app/graph': typeof AppGraphRoute
|
||||
'/app/inbox': typeof AppInboxRoute
|
||||
'/app/search': typeof AppSearchRoute
|
||||
'/app/todo': typeof AppTodoRoute
|
||||
'/app/': typeof AppIndexRoute
|
||||
'/app/collection/$id': typeof AppCollectionIdRoute
|
||||
'/app/note/$id': typeof AppNoteIdRoute
|
||||
'/app/term/$term': typeof AppTermTermRoute
|
||||
}
|
||||
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/app'
|
||||
| '/app/calendar'
|
||||
| '/app/graph'
|
||||
| '/app/inbox'
|
||||
| '/app/search'
|
||||
| '/app/todo'
|
||||
| '/app/'
|
||||
| '/app/collection/$id'
|
||||
| '/app/note/$id'
|
||||
| '/app/term/$term'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to:
|
||||
| '/'
|
||||
| '/app/calendar'
|
||||
| '/app/graph'
|
||||
| '/app/inbox'
|
||||
| '/app/search'
|
||||
| '/app/todo'
|
||||
| '/app'
|
||||
| '/app/collection/$id'
|
||||
| '/app/note/$id'
|
||||
| '/app/term/$term'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/app'
|
||||
| '/app/calendar'
|
||||
| '/app/graph'
|
||||
| '/app/inbox'
|
||||
| '/app/search'
|
||||
| '/app/todo'
|
||||
| '/app/'
|
||||
| '/app/collection/$id'
|
||||
| '/app/note/$id'
|
||||
| '/app/term/$term'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AppRoute: typeof AppRouteWithChildren
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AppRoute: AppRouteWithChildren,
|
||||
}
|
||||
|
||||
export const routeTree = rootRoute
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
/* ROUTE_MANIFEST_START
|
||||
{
|
||||
"routes": {
|
||||
"__root__": {
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/app"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/app": {
|
||||
"filePath": "app.tsx",
|
||||
"children": [
|
||||
"/app/calendar",
|
||||
"/app/graph",
|
||||
"/app/inbox",
|
||||
"/app/search",
|
||||
"/app/todo",
|
||||
"/app/",
|
||||
"/app/collection/$id",
|
||||
"/app/note/$id",
|
||||
"/app/term/$term"
|
||||
]
|
||||
},
|
||||
"/app/calendar": {
|
||||
"filePath": "app/calendar.tsx",
|
||||
"parent": "/app"
|
||||
},
|
||||
"/app/graph": {
|
||||
"filePath": "app/graph.tsx",
|
||||
"parent": "/app"
|
||||
},
|
||||
"/app/inbox": {
|
||||
"filePath": "app/inbox.tsx",
|
||||
"parent": "/app"
|
||||
},
|
||||
"/app/search": {
|
||||
"filePath": "app/search.tsx",
|
||||
"parent": "/app"
|
||||
},
|
||||
"/app/todo": {
|
||||
"filePath": "app/todo.tsx",
|
||||
"parent": "/app"
|
||||
},
|
||||
"/app/": {
|
||||
"filePath": "app/index.tsx",
|
||||
"parent": "/app"
|
||||
},
|
||||
"/app/collection/$id": {
|
||||
"filePath": "app/collection.$id.tsx",
|
||||
"parent": "/app"
|
||||
},
|
||||
"/app/note/$id": {
|
||||
"filePath": "app/note.$id.tsx",
|
||||
"parent": "/app"
|
||||
},
|
||||
"/app/term/$term": {
|
||||
"filePath": "app/term.$term.tsx",
|
||||
"parent": "/app"
|
||||
}
|
||||
}
|
||||
}
|
||||
ROUTE_MANIFEST_END */
|
|
@ -1,9 +1,14 @@
|
|||
import { Outlet } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
// import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { useEffect } from "react";
|
||||
import { getSerwist } from "virtual:serwist";
|
||||
import { ThemeProvider } from "~/hooks/use-theme";
|
||||
|
||||
export function RootLayout() {
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
});
|
||||
|
||||
function RootLayout() {
|
||||
useEffect(() => {
|
||||
const loadSerwist = async () => {
|
||||
if ("serviceWorker" in navigator) {
|
||||
|
@ -20,9 +25,9 @@ export function RootLayout() {
|
|||
loadSerwist();
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<ThemeProvider>
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
{/*<TanStackRouterDevtools />*/}
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
52
src/routes/app.tsx
Normal file
52
src/routes/app.tsx
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
||||
import { PanelLeft } from "lucide-react";
|
||||
import { AppSidebar } from "~/components/app_sidebar";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import { SidebarInset, SidebarProvider, useSidebar } from "~/components/ui/sidebar";
|
||||
import { MetadataProvider } from "~/hooks/use-metadata";
|
||||
import { useIsMobile } from "~/hooks/use-mobile";
|
||||
|
||||
export const Route = createFileRoute("/app")({
|
||||
component: AppLayout,
|
||||
});
|
||||
|
||||
export function AppLayout() {
|
||||
return (
|
||||
<>
|
||||
<MetadataProvider>
|
||||
<SidebarProvider>
|
||||
<AppSidebar />
|
||||
<div className="flex flex-col h-svh w-full group">
|
||||
<SidebarInset className="flex-grow overflow-scroll">
|
||||
<Outlet />
|
||||
</SidebarInset>
|
||||
<MobileBar />
|
||||
</div>
|
||||
</SidebarProvider>
|
||||
</MetadataProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileBar() {
|
||||
const isMobile = useIsMobile();
|
||||
const { toggleSidebar } = useSidebar();
|
||||
|
||||
if (!isMobile) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-sidebar w-full p-2">
|
||||
<Button
|
||||
data-sidebar="trigger"
|
||||
variant="ghost"
|
||||
onClick={toggleSidebar}
|
||||
className="justify-start"
|
||||
>
|
||||
<PanelLeft />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
9
src/routes/app/calendar.tsx
Normal file
9
src/routes/app/calendar.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/app/calendar')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/app/calendar"!</div>
|
||||
}
|
20
src/routes/app/collection.$id.tsx
Normal file
20
src/routes/app/collection.$id.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { CollectionHeader } from "~/components/collection/collection_header";
|
||||
import { NotesGrid } from "~/components/note/notes_grid";
|
||||
import { useCollectionNotesMetadata } from "~/hooks/use-metadata";
|
||||
|
||||
export const Route = createFileRoute("/app/collection/$id")({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
const notes = useCollectionNotesMetadata(id);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<CollectionHeader collectionId={id} />
|
||||
<NotesGrid notes={notes} />
|
||||
</div>
|
||||
);
|
||||
}
|
9
src/routes/app/graph.tsx
Normal file
9
src/routes/app/graph.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/app/graph')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/app/graph"!</div>
|
||||
}
|
15
src/routes/app/inbox.tsx
Normal file
15
src/routes/app/inbox.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import { NotesGrid } from '~/components/note/notes_grid';
|
||||
import { useCollectionNotesMetadata } from '~/hooks/use-metadata';
|
||||
|
||||
export const Route = createFileRoute('/app/inbox')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const notes = useCollectionNotesMetadata("")
|
||||
|
||||
return (
|
||||
<NotesGrid notes={notes} />
|
||||
);
|
||||
}
|
12
src/routes/app/index.tsx
Normal file
12
src/routes/app/index.tsx
Normal file
|
@ -0,0 +1,12 @@
|
|||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { MetadataInspector } from "~/components/yjs/metadata-inspector";
|
||||
|
||||
export const Route = createFileRoute("/app/")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
return (
|
||||
<MetadataInspector />
|
||||
)
|
||||
}
|
36
src/routes/app/note.$id.tsx
Normal file
36
src/routes/app/note.$id.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import NoteCanvas from "~/components/note/note_canvas";
|
||||
import { NoteHeader } from "~/components/note/note_header";
|
||||
import { Editor } from "~/editor/Editor";
|
||||
import { useNoteMetadata } from "~/hooks/use-metadata";
|
||||
import { NoteProvider } from "~/hooks/use-note";
|
||||
|
||||
export const Route = createFileRoute("/app/note/$id")({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const { id } = Route.useParams();
|
||||
return (
|
||||
<NoteProvider id={id}>
|
||||
<Content id={id} />
|
||||
</NoteProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Content(props: { id: string }) {
|
||||
const metadata = useNoteMetadata(props.id);
|
||||
|
||||
switch (metadata?.get("type")) {
|
||||
case "text": return <>
|
||||
<NoteHeader id={props.id} />
|
||||
<div className="flex-shrink-0 min-h-full max-w-3xl mx-auto w-full">
|
||||
<Editor noteId={props.id} />
|
||||
</div>
|
||||
</>;
|
||||
case "canvas": return <>
|
||||
<NoteCanvas noteId={props.id} />
|
||||
</>;
|
||||
default: return null;
|
||||
}
|
||||
}
|
13
src/routes/app/search.tsx
Normal file
13
src/routes/app/search.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { NotesGrid } from "~/components/note/notes_grid";
|
||||
import { useNotesMetadata } from "~/hooks/use-metadata";
|
||||
|
||||
export const Route = createFileRoute("/app/search")({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const notes = useNotesMetadata().toArray();
|
||||
|
||||
return <NotesGrid notes={notes} allUnpinned />
|
||||
}
|
10
src/routes/app/term.$term.tsx
Normal file
10
src/routes/app/term.$term.tsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/app/term/$term')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
const { term } = Route.useParams();
|
||||
return <div>Hello "/app/term/{term}"!</div>
|
||||
}
|
9
src/routes/app/todo.tsx
Normal file
9
src/routes/app/todo.tsx
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/app/todo')({
|
||||
component: RouteComponent,
|
||||
})
|
||||
|
||||
function RouteComponent() {
|
||||
return <div>Hello "/app/todo"!</div>
|
||||
}
|
|
@ -1,6 +1,11 @@
|
|||
import { createFileRoute, Link } from '@tanstack/react-router';
|
||||
import logo from '~/logo.svg'
|
||||
|
||||
export function RootPage() {
|
||||
export const Route = createFileRoute("/")({
|
||||
component: RootPage,
|
||||
});
|
||||
|
||||
function RootPage() {
|
||||
return (
|
||||
<div className="text-center">
|
||||
<header className="min-h-screen flex flex-col items-center justify-center bg-[#282c34] text-white text-[calc(10px+2vmin)]">
|
||||
|
@ -28,6 +33,11 @@ export function RootPage() {
|
|||
>
|
||||
Learn TanStack
|
||||
</a>
|
||||
<Link
|
||||
to='/app'
|
||||
>
|
||||
Go to app
|
||||
</Link>
|
||||
</header>
|
||||
</div>
|
||||
);
|
|
@ -1,7 +1,7 @@
|
|||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@custom-variant dark (&:is([data-theme="dark"] *));
|
||||
|
||||
body {
|
||||
@apply m-0;
|
||||
|
@ -52,7 +52,7 @@ code {
|
|||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
[data-theme="dark"] {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
|
|
|
@ -2,12 +2,14 @@ import { defineConfig } from "vite";
|
|||
import { serwist } from "@serwist/vite";
|
||||
import viteReact from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||
import path from "path";
|
||||
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterVite(),
|
||||
viteReact(),
|
||||
tailwindcss(),
|
||||
serwist({
|
||||
|
@ -16,6 +18,8 @@ export default defineConfig({
|
|||
globDirectory: "dist",
|
||||
injectionPoint: "self.__SW_MANIFEST",
|
||||
rollupFormat: "iife",
|
||||
// Insanely large max size, since the app **HAS** to function fully offline
|
||||
maximumFileSizeToCacheInBytes: 512 * 1024 * 1024,
|
||||
}),
|
||||
],
|
||||
server: {
|
||||
|
|
Loading…
Add table
Reference in a new issue