feat: migrate dashboard to svelte 5 (#577)

* chore: migration to SvelteKit 2

* stage 1: migrate to svelte 5 and fix rerendering issues

* stage 2: fix more $: reactivity across dashboard

* fix merge conflicts

* chore: migrate code in main to svelte 5

* chore: update preventDefault

* chore: simplify tsconfig and remove unused mobileMenuRef in MobileMenu.svelte

* chore: enforce .ts across dashboard

* rename from classroomio-com to website

* stage-3: Replace carbon icon svelte with lucide icon

* update lock file

* chore: finish icon replacement

* fix: settings and setup page multiple rerender

* stage 3: More icon and rerendering fixes

* feat: checkpoint

* feat: update editor

* [not working] connect everything with ui build

* storybook + ui + dashboard all working independently

* add bg-black to +page.svelte

* feat: add editor to storybook

* chore: animate lucid icons (#590)

* v1 animations

* feat: change some course navigation icons & animate org navigation icons

* chore: change settings icon

* chore: use css instead of js for hover

* rename animations

---------

Co-authored-by: tunny17 <[email protected]>

* chore: update path to node_modules types in api tsconfig

* fix: update import path for DropdownMenu component

* fix: add rollupOptions to build configuration in vite.config.ts

* fix: remove rollupOptions from build configuration and add @floating-ui/dom dependency

* install

---------

Co-authored-by: tunny17 <[email protected]>
This commit is contained in:
Rotimi Best
2025-11-01 16:18:29 +01:00
committed by GitHub
co-authored by tunny17
parent 0fb66a3782
commit f5a4156c8b
159 changed files with 15225 additions and 3722 deletions
+17
View File
@@ -0,0 +1,17 @@
import type { StorybookConfig } from '@storybook/sveltekit';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|ts|svelte)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-svelte-csf'
],
framework: {
name: '@storybook/sveltekit',
options: {}
}
};
export default config;
+16
View File
@@ -0,0 +1,16 @@
import type { Preview } from '@storybook/svelte';
import '../src/index.css';
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i
}
}
}
};
export default preview;
+19
View File
@@ -0,0 +1,19 @@
# @cio/storybook
Storybook for ClassroomIO UI components.
## Development
To start the Storybook development server:
```bash
pnpm dev
```
## Build
To build the Storybook:
```bash
pnpm build
```
+34
View File
@@ -0,0 +1,34 @@
{
"name": "@cio/storybook",
"version": "0.0.1",
"type": "module",
"private": true,
"scripts": {
"dev": "storybook dev -p 6006",
"build": "storybook build",
"preview": "storybook preview",
"build-storybook": "storybook build"
},
"dependencies": {
"@cio/ui": "workspace:*"
},
"devDependencies": {
"@storybook/addon-essentials": "^8.4.2",
"@storybook/addon-interactions": "^8.4.2",
"@storybook/addon-links": "^8.4.2",
"@storybook/addon-svelte-csf": "^5.0.6",
"@storybook/blocks": "^8.4.2",
"@storybook/svelte": "^8.4.2",
"@storybook/svelte-vite": "^10.0.2",
"@storybook/sveltekit": "^8.4.2",
"@storybook/test": "^8.4.2",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@tailwindcss/vite": "^4.1.16",
"storybook": "^8.4.2",
"svelte": "^5.39.11",
"tailwindcss": "^4.1.14",
"@cio/tsconfig": "workspace:*",
"typescript-svelte-plugin": "^0.3.50",
"vite": "^7.1.4"
}
}
+12
View File
@@ -0,0 +1,12 @@
// See https://kit.svelte.dev/docs/types#app
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface Platform {}
}
}
export {};
@@ -0,0 +1,100 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Button } from '@cio/ui/base/button';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Atom/Button',
component: Button,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
<Button>Default Button</Button>
{/snippet}
</Story>
<Story name="Secondary">
{#snippet template(args)}
<Button {...args} variant="secondary">Secondary Button</Button>
{/snippet}
</Story>
<Story name="Destructive">
{#snippet template(args)}
<Button {...args} variant="destructive">Destructive Button</Button>
{/snippet}
</Story>
<Story name="Outline">
{#snippet template(args)}
<Button {...args} variant="outline">Outline Button</Button>
{/snippet}
</Story>
<Story name="Ghost">
{#snippet template(args)}
<Button {...args} variant="ghost">Ghost Button</Button>
{/snippet}
</Story>
<Story name="Link">
{#snippet template(args)}
<Button {...args} variant="link">Link Button</Button>
{/snippet}
</Story>
<Story name="Small">
{#snippet template(args)}
<Button {...args} size="sm">Small Button</Button>
{/snippet}
</Story>
<Story name="Large">
{#snippet template(args)}
<Button {...args} size="lg">Large Button</Button>
{/snippet}
</Story>
<Story name="Disabled">
{#snippet template(args)}
<Button {...args} disabled>Disabled Button</Button>
{/snippet}
</Story>
<Story name="As Link">
{#snippet template(args)}
<Button {...args} href="#">Link Button</Button>
{/snippet}
</Story>
<Story name="All Variants">
{#snippet template()}
<div class="flex flex-wrap gap-4">
<Button>Default</Button>
<Button variant="secondary">Secondary</Button>
<Button variant="destructive">Destructive</Button>
<Button variant="outline">Outline</Button>
<Button variant="ghost">Ghost</Button>
<Button variant="link">Link</Button>
</div>
{/snippet}
</Story>
<Story name="All Sizes">
{#snippet template()}
<div class="flex items-center gap-4">
<Button size="sm">Small</Button>
<Button>Default</Button>
<Button size="lg">Large</Button>
</div>
{/snippet}
</Story>
@@ -0,0 +1,4 @@
import { type ComponentProps } from 'svelte';
import { Button } from '@cio/ui/base/button';
export const FIELDS: (keyof ComponentProps<typeof Button>)[] = ['variant', 'size', 'disabled', 'class', 'type', 'href'];
@@ -0,0 +1,117 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { DropdownMenu, Button } from '@cio/ui';
const { Story } = defineMeta({
title: 'Atom/DropdownMenu',
component: DropdownMenu.Root,
parameters: {
layout: 'centered'
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button.Root variant="outline">Open</Button.Root>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Group>
<DropdownMenu.Label>My Account</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.Item>Profile</DropdownMenu.Item>
<DropdownMenu.Item>Billing</DropdownMenu.Item>
<DropdownMenu.Item>Settings</DropdownMenu.Item>
<DropdownMenu.Item>Keyboard shortcuts</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Group>
<DropdownMenu.Item>Team</DropdownMenu.Item>
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger>Invite users</DropdownMenu.SubTrigger>
<DropdownMenu.SubContent>
<DropdownMenu.Item>Email</DropdownMenu.Item>
<DropdownMenu.Item>Message</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>More...</DropdownMenu.Item>
</DropdownMenu.SubContent>
</DropdownMenu.Sub>
<DropdownMenu.Item>New Team</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item>GitHub</DropdownMenu.Item>
<DropdownMenu.Item>Support</DropdownMenu.Item>
<DropdownMenu.Item disabled>API</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>Log out</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
</Story>
<Story name="With Icons">
{#snippet template()}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button.Root variant="outline">Open</Button.Root>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item>
<span>👤 Profile</span>
</DropdownMenu.Item>
<DropdownMenu.Item>
<span>💳 Billing</span>
</DropdownMenu.Item>
<DropdownMenu.Item>
<span>⚙️ Settings</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>
<span>🚪 Logout</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
</Story>
<Story name="Simple Menu">
{#snippet template()}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button.Root variant="outline">Open</Button.Root>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Item>Edit</DropdownMenu.Item>
<DropdownMenu.Item>Duplicate</DropdownMenu.Item>
<DropdownMenu.Item>Archive</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item class="text-red-600">Delete</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
</Story>
<Story name="With Checkboxes">
{#snippet template()}
<DropdownMenu.Root>
<DropdownMenu.Trigger>
<Button.Root variant="outline">Open</Button.Root>
</DropdownMenu.Trigger>
<DropdownMenu.Content>
<DropdownMenu.Label>View Options</DropdownMenu.Label>
<DropdownMenu.Separator />
<DropdownMenu.CheckboxItem checked>Status Bar</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem>Activity Bar</DropdownMenu.CheckboxItem>
<DropdownMenu.CheckboxItem checked>Panel</DropdownMenu.CheckboxItem>
<DropdownMenu.Separator />
<DropdownMenu.RadioGroup value="single">
<DropdownMenu.RadioItem value="single">Single</DropdownMenu.RadioItem>
<DropdownMenu.RadioItem value="group">Group</DropdownMenu.RadioItem>
</DropdownMenu.RadioGroup>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
</Story>
@@ -0,0 +1,14 @@
import { type ComponentProps } from 'svelte';
import { Input } from '@cio/ui/base/input';
export const FIELDS: (keyof ComponentProps<typeof Input>)[] = [
'value',
'type',
'placeholder',
'disabled',
'class',
'id',
'name',
'required',
'readonly'
];
@@ -0,0 +1,66 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Input } from '@cio/ui/base/input';
import { Label } from '@cio/ui/base/label';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Atom/Input',
component: Input,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Default" args={{ placeholder: 'Enter text...' }} />
<Story name="With Label">
{#snippet template()}
<div class="grid w-full max-w-sm items-center gap-1.5">
<Label for="email">Email</Label>
<Input type="email" id="email" placeholder="Email" />
</div>
{/snippet}
</Story>
<Story name="Password">
{#snippet template()}
<Input type="password" placeholder="Password" />
{/snippet}
</Story>
<Story name="Disabled">
{#snippet template()}
<Input disabled placeholder="Disabled input" />
{/snippet}
</Story>
<Story name="With Error">
{#snippet template()}
<div class="grid w-full max-w-sm items-center gap-1.5">
<Label for="email-error">Email</Label>
<Input type="email" id="email-error" placeholder="Email" class="border-red-500" />
<p class="text-sm text-red-500">This field is required</p>
</div>
{/snippet}
</Story>
<Story name="File Input">
{#snippet template()}
<div class="grid w-full max-w-sm items-center gap-1.5">
<Label for="picture">Picture</Label>
<Input id="picture" type="file" />
</div>
{/snippet}
</Story>
<Story name="Search">
{#snippet template()}
<Input type="search" placeholder="Search..." />
{/snippet}
</Story>
@@ -0,0 +1,109 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Popover, Button, Input, Label } from '@cio/ui';
const { Story } = defineMeta({
title: 'Atom/Popover',
component: Popover.Root,
parameters: {
layout: 'centered'
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
<Popover.Root>
<Popover.Trigger>
<Button.Root variant="outline">Open popover</Button.Root>
</Popover.Trigger>
<Popover.Content class="w-80">
<div class="grid gap-4">
<div class="space-y-2">
<h4 class="font-medium leading-none">Dimensions</h4>
<p class="text-muted-foreground text-sm">Set the dimensions for the layer.</p>
</div>
<div class="grid gap-2">
<div class="grid grid-cols-3 items-center gap-4">
<Label.Root for="width">Width</Label.Root>
<Input.Root id="width" value="100%" class="col-span-2 h-8" />
</div>
<div class="grid grid-cols-3 items-center gap-4">
<Label.Root for="maxWidth">Max. width</Label.Root>
<Input.Root id="maxWidth" value="300px" class="col-span-2 h-8" />
</div>
<div class="grid grid-cols-3 items-center gap-4">
<Label.Root for="height">Height</Label.Root>
<Input.Root id="height" value="25px" class="col-span-2 h-8" />
</div>
<div class="grid grid-cols-3 items-center gap-4">
<Label.Root for="maxHeight">Max. height</Label.Root>
<Input.Root id="maxHeight" value="none" class="col-span-2 h-8" />
</div>
</div>
</div>
</Popover.Content>
</Popover.Root>
{/snippet}
</Story>
<Story name="Simple">
{#snippet template()}
<Popover.Root>
<Popover.Trigger>
<Button.Root>Click me</Button.Root>
</Popover.Trigger>
<Popover.Content>
<p class="text-sm">This is a simple popover with some text content.</p>
</Popover.Content>
</Popover.Root>
{/snippet}
</Story>
<Story name="With Form">
{#snippet template()}
<Popover.Root>
<Popover.Trigger>
<Button.Root variant="outline">Settings</Button.Root>
</Popover.Trigger>
<Popover.Content class="w-80">
<div class="grid gap-4">
<div class="space-y-2">
<h4 class="font-medium leading-none">Settings</h4>
<p class="text-muted-foreground text-sm">Manage your account settings.</p>
</div>
<div class="grid gap-2">
<div class="grid gap-1">
<Label.Root for="name">Display name</Label.Root>
<Input.Root id="name" value="Pedro Duarte" />
</div>
<div class="grid gap-1">
<Label.Root for="username">Username</Label.Root>
<Input.Root id="username" value="@peduarte" />
</div>
</div>
<Button.Root size="sm" class="w-full">Save changes</Button.Root>
</div>
</Popover.Content>
</Popover.Root>
{/snippet}
</Story>
<Story name="Information">
{#snippet template()}
<Popover.Root>
<Popover.Trigger>
<Button.Root variant="ghost" size="sm">ℹ️ Info</Button.Root>
</Popover.Trigger>
<Popover.Content class="w-56">
<div class="space-y-2">
<h4 class="font-medium">About this feature</h4>
<p class="text-muted-foreground text-sm">
This feature allows you to quickly access important information without leaving the current page.
</p>
</div>
</Popover.Content>
</Popover.Root>
{/snippet}
</Story>
@@ -0,0 +1,4 @@
import { type ComponentProps } from 'svelte';
import { Skeleton } from '@cio/ui/base/skeleton';
export const FIELDS: (keyof ComponentProps<typeof Skeleton>)[] = ['class'];
@@ -0,0 +1,77 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Skeleton } from '@cio/ui/base/skeleton';
import { FIELDS } from './fields';
const { Story } = defineMeta({
title: 'Atom/Skeleton',
component: Skeleton,
parameters: {
layout: 'centered',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template(args)}
<Skeleton {...args} class="h-4 w-[250px]" />
{/snippet}
</Story>
<Story name="Circle">
{#snippet template(args)}
<Skeleton {...args} class="h-12 w-12 rounded-full" />
{/snippet}
</Story>
<Story name="Card">
{#snippet template()}
<div class="flex items-center space-x-4">
<Skeleton class="h-12 w-12 rounded-full" />
<div class="space-y-2">
<Skeleton class="h-4 w-[250px]" />
<Skeleton class="h-4 w-[200px]" />
</div>
</div>
{/snippet}
</Story>
<Story name="Multiple Lines">
{#snippet template()}
<div class="space-y-2">
<Skeleton class="h-4 w-[350px]" />
<Skeleton class="h-4 w-[300px]" />
<Skeleton class="h-4 w-[250px]" />
<Skeleton class="h-4 w-[200px]" />
</div>
{/snippet}
</Story>
<Story name="Button">
{#snippet template(args)}
<Skeleton {...args} class="h-10 w-20" />
{/snippet}
</Story>
<Story name="Loading State">
{#snippet template()}
<div class="space-y-4 rounded-lg border p-4">
<div class="flex items-center space-x-4">
<Skeleton class="h-12 w-12 rounded-full" />
<div class="space-y-2">
<Skeleton class="h-4 w-[200px]" />
<Skeleton class="h-4 w-40" />
</div>
</div>
<Skeleton class="h-[200px] w-full" />
<div class="flex space-x-2">
<Skeleton class="h-8 w-16" />
<Skeleton class="h-8 w-16" />
</div>
</div>
{/snippet}
</Story>
@@ -0,0 +1,14 @@
<script>
import { Tooltip, Button } from '@cio/ui';
</script>
<Tooltip.Provider>
<Tooltip.Root>
<Tooltip.Trigger>
<Button.Root variant="outline">Hover me</Button.Root>
</Tooltip.Trigger>
<Tooltip.Content>
<p>Add to library</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
@@ -0,0 +1,130 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Tooltip, Provider, Trigger, Content } from '@cio/ui/base/tooltip';
import { Button } from '@cio/ui/base/button';
// import { Arro}
const { Story } = defineMeta({
title: 'Atom/Tooltip',
component: Provider,
parameters: {
layout: 'centered'
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
<Provider>
<Tooltip>
<Trigger>
<Button variant="outline">Hover me</Button>
</Trigger>
<Content>
<p>Add to library</p>
</Content>
</Tooltip>
</Provider>
{/snippet}
</Story>
<Story name="With Arrow">
{#snippet template()}
<Provider>
<Tooltip>
<Trigger>
<Button>Hover for tooltip</Button>
</Trigger>
<Content>
<p>This tooltip has an arrow pointing to the trigger</p>
<!-- <Arrow /> -->
=>
</Content>
</Tooltip>
</Provider>
{/snippet}
</Story>
<Story name="Simple Text">
{#snippet template()}
<Provider>
<Tooltip>
<Trigger>
<Button variant="ghost" size="sm">❓</Button>
</Trigger>
<Content>
<p>Help information</p>
</Content>
</Tooltip>
</Provider>
{/snippet}
</Story>
<Story name="Multiple Tooltips">
{#snippet template()}
<Provider>
<div class="flex gap-4">
<Tooltip>
<Trigger>
<Button variant="outline">Save</Button>
</Trigger>
<Content>
<p>Save your work (Ctrl+S)</p>
</Content>
</Tooltip>
<Tooltip>
<Trigger>
<Button variant="outline">Edit</Button>
</Trigger>
<Content>
<p>Edit this item</p>
</Content>
</Tooltip>
<Tooltip>
<Trigger>
<Button variant="destructive">Delete</Button>
</Trigger>
<Content>
<p>Permanently delete this item</p>
</Content>
</Tooltip>
</div>
</Provider>
{/snippet}
</Story>
<Story name="Icon Button">
{#snippet template()}
<Provider>
<Tooltip>
<Trigger>
<Button variant="ghost" size="sm">🔧</Button>
</Trigger>
<Content>
<p>Settings</p>
</Content>
</Tooltip>
</Provider>
{/snippet}
</Story>
<Story name="Long Content">
{#snippet template()}
<Provider>
<Tooltip>
<Trigger>
<Button variant="outline">Info</Button>
</Trigger>
<Content class="max-w-xs">
<p>
This is a longer tooltip with more detailed information that might span multiple lines to provide
comprehensive context about the feature or action.
</p>
</Content>
</Tooltip>
</Provider>
{/snippet}
</Story>
+3
View File
@@ -0,0 +1,3 @@
@import 'tailwindcss';
@import '@cio/ui/output.css';
@@ -0,0 +1,62 @@
import type { EditorProps } from '@cio/ui/custom/editor/types';
export const FIELDS: (keyof EditorProps)[] = [
'showToolBar',
'editable',
'enablePersistence',
'contentStorageKey',
'editableStorageKey',
'class',
'editorClass'
];
export const DEFAULT_CONTENT = {
type: 'doc',
content: [
{
type: 'heading',
attrs: { level: 1 },
content: [{ type: 'text', text: 'Welcome to the Editor' }]
},
{
type: 'paragraph',
content: [
{ type: 'text', text: 'This is a ' },
{ type: 'text', marks: [{ type: 'bold' }], text: 'rich text editor' },
{ type: 'text', text: ' with support for various formatting options.' }
]
},
{
type: 'bulletList',
content: [
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Support for lists' }]
}
]
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Rich text formatting' }]
}
]
},
{
type: 'listItem',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'And much more!' }]
}
]
}
]
}
]
};
@@ -0,0 +1,82 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import { Editor } from '@cio/ui/custom/editor';
import { FIELDS, DEFAULT_CONTENT } from './constants';
const { Story } = defineMeta({
title: 'Molecules/Editor',
component: Editor,
parameters: {
layout: 'padded',
controls: {
include: FIELDS
}
},
tags: ['autodocs']
});
</script>
<Story name="Default">
{#snippet template()}
<Editor />
{/snippet}
</Story>
<Story name="With Sample Content">
{#snippet template()}
<Editor content={DEFAULT_CONTENT} />
{/snippet}
</Story>
<Story name="Read Only">
{#snippet template()}
<Editor content={DEFAULT_CONTENT} editable={false} />
{/snippet}
</Story>
<Story name="Without Toolbar">
{#snippet template()}
<Editor content={DEFAULT_CONTENT} showToolBar={false} />
{/snippet}
</Story>
<Story name="With Persistence">
{#snippet template()}
<Editor content={DEFAULT_CONTENT} enablePersistence={true} contentStorageKey="storybook-editor-content" />
{/snippet}
</Story>
<Story name="Custom Styling">
{#snippet template()}
<Editor
content={DEFAULT_CONTENT}
class="mx-auto max-w-4xl rounded-lg border-2 border-blue-300"
editorClass="h-64 p-4 bg-gray-50"
/>
{/snippet}
</Story>
<Story name="With Callbacks">
{#snippet template()}
<Editor
content={DEFAULT_CONTENT}
onContentChange={(content) => console.log('Content changed:', content)}
onEditorReady={(editor) => console.log('Editor ready:', editor)}
/>
{/snippet}
</Story>
<Story name="Multiple Editors">
{#snippet template()}
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<h3 class="mb-2 text-lg font-semibold">Article</h3>
<Editor content={DEFAULT_CONTENT} contentStorageKey="article-content" enablePersistence={true} />
</div>
<div>
<h3 class="mb-2 text-lg font-semibold">Notes</h3>
<Editor showToolBar={false} editorClass="h-48" contentStorageKey="notes-content" enablePersistence={true} />
</div>
</div>
{/snippet}
</Story>
+16
View File
@@ -0,0 +1,16 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
// Consult https://kit.svelte.dev/docs/integrations#preprocessors
// for more information about preprocessors
preprocess: vitePreprocess(),
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
}
};
export default config;
+36
View File
@@ -0,0 +1,36 @@
{
"extends": "@cio/tsconfig/base.json",
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"sourceMap": true,
"strict": true,
"target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', 'ES2021', or 'ESNEXT'. */,
"module": "ESNext",
"moduleResolution": "bundler",
"noImplicitAny": false,
"noUnusedLocals": true,
"noUnusedParameters": true,
"paths": {
"@cio/ui": ["../ui/src"],
"@cio/ui/*": ["../ui/src/*"],
"$src/*": ["../ui/*"]
},
"typeRoots": ["../../node_modules/@types", "./node_modules/@types"],
"types": ["node"],
"jsx": "preserve",
"lib": ["ES2020", "DOM"],
"plugins": [
{
"name": "typescript-svelte-plugin",
"enabled": true,
"assumeIsSvelteProject": true
}
]
},
"include": ["src/**/*", ".storybook/**/*", "vite.config.ts"]
}
+22
View File
@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import * as path from 'path';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [tailwindcss(), svelte()],
resolve: {
alias: {
'@cio/ui': path.resolve(__dirname, '../ui/src'),
$src: path.resolve(__dirname, '../ui/src')
},
conditions: ['svelte', 'browser', 'import']
},
optimizeDeps: {
include: ['svelte-tiptap'],
exclude: ['@tiptap/core', '@tiptap/pm']
},
ssr: {
noExternal: ['svelte-tiptap']
}
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cio/tsconfig",
"version": "0.0.0",
"version": "0.0.1",
"private": true,
"license": "MIT",
"publishConfig": {
+15
View File
@@ -0,0 +1,15 @@
{
"$schema": "https://shadcn-svelte.com/schema.json",
"tailwind": {
"css": "src/index.css",
"baseColor": "slate"
},
"aliases": {
"components": "$src",
"utils": "$src/tools/index",
"ui": "$src/base",
"hooks": "$src/hooks"
},
"typescript": true,
"registry": "https://shadcn-svelte.com/registry"
}
+56
View File
@@ -0,0 +1,56 @@
{
"name": "@cio/ui",
"version": "0.0.1",
"type": "module",
"scripts": {
"generate-css": "echo 'generating CSS' && pnpx @tailwindcss/cli -i ./src/index.css -o ./src/output.css --minify",
"build": "pnpm generate-css",
"dev": "pnpm generate-css --watch"
},
"devDependencies": {
"@internationalized/date": "^3.8.1",
"@lucide/svelte": "^0.544.0",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.5.27",
"@sveltejs/vite-plugin-svelte": "^4.0.4",
"@tailwindcss/typography": "^0.5.18",
"bits-ui": "^2.11.7",
"clsx": "^2.1.1",
"svelte": "^5.39.11",
"tailwind-merge": "^3.3.1",
"tailwind-variants": "^3.1.1",
"tailwindcss": "^4.1.14",
"tw-animate-css": "^1.4.0"
},
"dependencies": {
"@aarkue/tiptap-math-extension": "^1.4.0",
"@floating-ui/dom": "^1.7.4",
"@lucide/svelte": "^0.546.0",
"@tiptap/core": "^3.7.1",
"@tiptap/extension-bubble-menu": "^3.7.1",
"@tiptap/extension-code-block-lowlight": "^3.7.1",
"@tiptap/extension-highlight": "^3.7.1",
"@tiptap/extension-image": "^3.7.1",
"@tiptap/extension-list": "^3.7.1",
"@tiptap/extension-subscript": "^3.7.1",
"@tiptap/extension-superscript": "^3.7.1",
"@tiptap/extension-table": "^3.7.1",
"@tiptap/extension-text-align": "^3.7.1",
"@tiptap/extension-text-style": "^3.7.1",
"@tiptap/extension-typography": "^3.7.1",
"@tiptap/extensions": "^3.7.1",
"@tiptap/pm": "^3.7.1",
"@tiptap/starter-kit": "^3.7.1",
"@tiptap/suggestion": "^3.7.1",
"katex": "^0.16.22",
"lowlight": "^3.3.0",
"mode-watcher": "^0.5.1",
"svelte-tiptap": "^3.0.0",
"tippy.js": "^6.3.7",
"tiptap-extension-auto-joiner": "^0.1.3",
"tiptap-markdown": "^0.9.0"
},
"peerDependencies": {
"bits-ui": "^2.11.0"
}
}
+82
View File
@@ -0,0 +1,82 @@
<script lang="ts" module>
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white',
outline:
'bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9',
'icon-sm': 'size-8',
'icon-lg': 'size-10'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
};
</script>
<script lang="ts">
let {
class: className,
variant = 'default',
size = 'default',
ref = $bindable(null),
href = undefined,
type = 'button',
disabled,
children,
...restProps
}: ButtonProps = $props();
</script>
{#if href}
<a
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
href={disabled ? undefined : href}
aria-disabled={disabled}
role={disabled ? 'link' : undefined}
tabindex={disabled ? -1 : undefined}
{...restProps}
>
{@render children?.()}
</a>
{:else}
<button
bind:this={ref}
data-slot="button"
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
{...restProps}
>
{@render children?.()}
</button>
{/if}
+17
View File
@@ -0,0 +1,17 @@
import Root, {
type ButtonProps,
type ButtonSize,
type ButtonVariant,
buttonVariants,
} from "./button.svelte";
export {
Root,
type ButtonProps as Props,
//
Root as Button,
buttonVariants,
type ButtonProps,
type ButtonSize,
type ButtonVariant,
};
@@ -0,0 +1,21 @@
<script lang="ts">
import SunIcon from '@lucide/svelte/icons/sun';
import MoonIcon from '@lucide/svelte/icons/moon';
import { resetMode, setMode } from 'mode-watcher';
import * as DropdownMenu from '$src/base/dropdown-menu';
import { buttonVariants } from '$src/base/button';
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger class={buttonVariants({ variant: 'outline', size: 'icon' })}>
<SunIcon class="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 !transition-all dark:-rotate-90 dark:scale-0" />
<MoonIcon class="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 !transition-all dark:rotate-0 dark:scale-100" />
<span class="sr-only">Toggle theme</span>
</DropdownMenu.Trigger>
<DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => setMode('light')}>Light</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => setMode('dark')}>Dark</DropdownMenu.Item>
<DropdownMenu.Item onclick={() => resetMode()}>System</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
+3
View File
@@ -0,0 +1,3 @@
export { default as ModeSwitcher } from './dark-mode.svelte';
export * from 'mode-watcher';
@@ -0,0 +1,41 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CheckIcon from '@lucide/svelte/icons/check';
import MinusIcon from '@lucide/svelte/icons/minus';
import { cn, type WithoutChildrenOrChild } from '../../tools';
import type { Snippet } from 'svelte';
let {
ref = $bindable(null),
checked = $bindable(false),
indeterminate = $bindable(false),
class: className,
children: childrenProp,
...restProps
}: WithoutChildrenOrChild<DropdownMenuPrimitive.CheckboxItemProps> & {
children?: Snippet;
} = $props();
</script>
<DropdownMenuPrimitive.CheckboxItem
bind:ref
bind:checked
bind:indeterminate
data-slot="dropdown-menu-checkbox-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked, indeterminate })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
{#if indeterminate}
<MinusIcon class="size-4" />
{:else}
<CheckIcon class={cn('size-4', !checked && 'text-transparent')} />
{/if}
</span>
{@render childrenProp?.()}
{/snippet}
</DropdownMenuPrimitive.CheckboxItem>
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from '../../tools';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
sideOffset = 4,
portalProps,
class: className,
...restProps
}: DropdownMenuPrimitive.ContentProps & {
portalProps?: DropdownMenuPrimitive.PortalProps;
} = $props();
</script>
<DropdownMenuPrimitive.Portal {...portalProps}>
<DropdownMenuPrimitive.Content
bind:ref
data-slot="dropdown-menu-content"
{sideOffset}
class={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 max-h-(--bits-dropdown-menu-content-available-height) origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border p-1 shadow-md outline-none',
className
)}
{...restProps}
/>
</DropdownMenuPrimitive.Portal>
@@ -0,0 +1,22 @@
<script lang="ts">
import { cn } from '../../tools';
import type { ComponentProps } from 'svelte';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
inset,
...restProps
}: ComponentProps<typeof DropdownMenuPrimitive.GroupHeading> & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.GroupHeading
bind:ref
data-slot="dropdown-menu-group-heading"
data-inset={inset}
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
{...restProps}
/>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.GroupProps = $props();
</script>
<DropdownMenuPrimitive.Group bind:ref data-slot="dropdown-menu-group" {...restProps} />
@@ -0,0 +1,27 @@
<script lang="ts">
import { cn } from '../../tools';
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
inset,
variant = 'default',
...restProps
}: DropdownMenuPrimitive.ItemProps & {
inset?: boolean;
variant?: 'default' | 'destructive';
} = $props();
</script>
<DropdownMenuPrimitive.Item
bind:ref
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:data-highlighted:bg-destructive/10 dark:data-[variant=destructive]:data-highlighted:bg-destructive/20 data-[variant=destructive]:data-highlighted:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
/>
@@ -0,0 +1,24 @@
<script lang="ts">
import { cn, type WithElementRef } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLDivElement>> & {
inset?: boolean;
} = $props();
</script>
<div
bind:this={ref}
data-slot="dropdown-menu-label"
data-inset={inset}
class={cn('px-2 py-1.5 text-sm font-semibold data-[inset]:pl-8', className)}
{...restProps}
>
{@render children?.()}
</div>
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let {
ref = $bindable(null),
value = $bindable(),
...restProps
}: DropdownMenuPrimitive.RadioGroupProps = $props();
</script>
<DropdownMenuPrimitive.RadioGroup
bind:ref
bind:value
data-slot="dropdown-menu-radio-group"
{...restProps}
/>
@@ -0,0 +1,31 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import CircleIcon from '@lucide/svelte/icons/circle';
import { cn, type WithoutChild } from '../../tools';
let {
ref = $bindable(null),
class: className,
children: childrenProp,
...restProps
}: WithoutChild<DropdownMenuPrimitive.RadioItemProps> = $props();
</script>
<DropdownMenuPrimitive.RadioItem
bind:ref
data-slot="dropdown-menu-radio-item"
class={cn(
"focus:bg-accent focus:text-accent-foreground outline-hidden relative flex cursor-default select-none items-center gap-2 rounded-sm py-1.5 pl-8 pr-2 text-sm data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{#snippet children({ checked })}
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
{#if checked}
<CircleIcon class="size-2 fill-current" />
{/if}
</span>
{@render childrenProp?.({ checked })}
{/snippet}
</DropdownMenuPrimitive.RadioItem>
@@ -0,0 +1,13 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '../../tools';
let { ref = $bindable(null), class: className, ...restProps }: DropdownMenuPrimitive.SeparatorProps = $props();
</script>
<DropdownMenuPrimitive.Separator
bind:ref
data-slot="dropdown-menu-separator"
class={cn('bg-border -mx-1 my-1 h-px', className)}
{...restProps}
/>
@@ -0,0 +1,20 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '../../tools';
let {
ref = $bindable(null),
class: className,
children,
...restProps
}: WithElementRef<HTMLAttributes<HTMLSpanElement>> = $props();
</script>
<span
bind:this={ref}
data-slot="dropdown-menu-shortcut"
class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)}
{...restProps}
>
{@render children?.()}
</span>
@@ -0,0 +1,16 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import { cn } from '../../tools';
let { ref = $bindable(null), class: className, ...restProps }: DropdownMenuPrimitive.SubContentProps = $props();
</script>
<DropdownMenuPrimitive.SubContent
bind:ref
data-slot="dropdown-menu-sub-content"
class={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 origin-(--bits-dropdown-menu-content-transform-origin) z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
className
)}
{...restProps}
/>
@@ -0,0 +1,29 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from 'bits-ui';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import { cn } from '../../tools';
let {
ref = $bindable(null),
class: className,
inset,
children,
...restProps
}: DropdownMenuPrimitive.SubTriggerProps & {
inset?: boolean;
} = $props();
</script>
<DropdownMenuPrimitive.SubTrigger
bind:ref
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
class={cn(
"data-highlighted:bg-accent data-highlighted:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground outline-hidden [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm data-[disabled]:pointer-events-none data-[inset]:pl-8 data-[disabled]:opacity-50 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
{...restProps}
>
{@render children?.()}
<ChevronRightIcon class="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
@@ -0,0 +1,7 @@
<script lang="ts">
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: DropdownMenuPrimitive.TriggerProps = $props();
</script>
<DropdownMenuPrimitive.Trigger bind:ref data-slot="dropdown-menu-trigger" {...restProps} />
@@ -0,0 +1,49 @@
import { DropdownMenu as DropdownMenuPrimitive } from "bits-ui";
import CheckboxItem from "./dropdown-menu-checkbox-item.svelte";
import Content from "./dropdown-menu-content.svelte";
import Group from "./dropdown-menu-group.svelte";
import Item from "./dropdown-menu-item.svelte";
import Label from "./dropdown-menu-label.svelte";
import RadioGroup from "./dropdown-menu-radio-group.svelte";
import RadioItem from "./dropdown-menu-radio-item.svelte";
import Separator from "./dropdown-menu-separator.svelte";
import Shortcut from "./dropdown-menu-shortcut.svelte";
import Trigger from "./dropdown-menu-trigger.svelte";
import SubContent from "./dropdown-menu-sub-content.svelte";
import SubTrigger from "./dropdown-menu-sub-trigger.svelte";
import GroupHeading from "./dropdown-menu-group-heading.svelte";
const Sub = DropdownMenuPrimitive.Sub;
const Root = DropdownMenuPrimitive.Root;
export {
CheckboxItem,
Content,
Root as DropdownMenu,
CheckboxItem as DropdownMenuCheckboxItem,
Content as DropdownMenuContent,
Group as DropdownMenuGroup,
Item as DropdownMenuItem,
Label as DropdownMenuLabel,
RadioGroup as DropdownMenuRadioGroup,
RadioItem as DropdownMenuRadioItem,
Separator as DropdownMenuSeparator,
Shortcut as DropdownMenuShortcut,
Sub as DropdownMenuSub,
SubContent as DropdownMenuSubContent,
SubTrigger as DropdownMenuSubTrigger,
Trigger as DropdownMenuTrigger,
GroupHeading as DropdownMenuGroupHeading,
Group,
GroupHeading,
Item,
Label,
RadioGroup,
RadioItem,
Root,
Separator,
Shortcut,
Sub,
SubContent,
SubTrigger,
Trigger,
};
+7
View File
@@ -0,0 +1,7 @@
import Root from "./input.svelte";
export {
Root,
//
Root as Input,
};
+51
View File
@@ -0,0 +1,51 @@
<script lang="ts">
import type { HTMLInputAttributes, HTMLInputTypeAttribute } from 'svelte/elements';
import { cn, type WithElementRef } from '../../tools';
type InputType = Exclude<HTMLInputTypeAttribute, 'file'>;
type Props = WithElementRef<
Omit<HTMLInputAttributes, 'type'> & ({ type: 'file'; files?: FileList } | { type?: InputType; files?: undefined })
>;
let {
ref = $bindable(null),
value = $bindable(),
type,
files = $bindable(),
class: className,
'data-slot': dataSlot = 'input',
...restProps
}: Props = $props();
</script>
{#if type === 'file'}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
'selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-input ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 pt-1.5 text-sm font-medium outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
type="file"
bind:files
bind:value
{...restProps}
/>
{:else}
<input
bind:this={ref}
data-slot={dataSlot}
class={cn(
'border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
{type}
bind:value
{...restProps}
/>
{/if}
+7
View File
@@ -0,0 +1,7 @@
import Root from "./label.svelte";
export {
Root,
//
Root as Label,
};
+16
View File
@@ -0,0 +1,16 @@
<script lang="ts">
import { Label as LabelPrimitive } from 'bits-ui';
import { cn } from '$src/tools/index.js';
let { ref = $bindable(null), class: className, ...restProps }: LabelPrimitive.RootProps = $props();
</script>
<LabelPrimitive.Root
bind:ref
data-slot="label"
class={cn(
'flex select-none items-center gap-2 text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50 group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50',
className
)}
{...restProps}
/>
+17
View File
@@ -0,0 +1,17 @@
import { Popover as PopoverPrimitive } from "bits-ui";
import Content from "./popover-content.svelte";
import Trigger from "./popover-trigger.svelte";
const Root = PopoverPrimitive.Root;
const Close = PopoverPrimitive.Close;
export {
Root,
Content,
Trigger,
Close,
//
Root as Popover,
Content as PopoverContent,
Trigger as PopoverTrigger,
Close as PopoverClose,
};
@@ -0,0 +1,29 @@
<script lang="ts">
import { cn } from '../../tools';
import { Popover as PopoverPrimitive } from 'bits-ui';
let {
ref = $bindable(null),
class: className,
sideOffset = 4,
align = 'center',
portalProps,
...restProps
}: PopoverPrimitive.ContentProps & {
portalProps?: PopoverPrimitive.PortalProps;
} = $props();
</script>
<PopoverPrimitive.Portal {...portalProps}>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={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 origin-(--bits-popover-content-transform-origin) outline-hidden z-50 w-72 rounded-md border p-4 shadow-md',
className
)}
{...restProps}
/>
</PopoverPrimitive.Portal>
@@ -0,0 +1,8 @@
<script lang="ts">
import { cn } from '../../tools';
import { Popover as PopoverPrimitive } from 'bits-ui';
let { ref = $bindable(null), class: className, ...restProps }: PopoverPrimitive.TriggerProps = $props();
</script>
<PopoverPrimitive.Trigger bind:ref data-slot="popover-trigger" class={cn('', className)} {...restProps} />
+7
View File
@@ -0,0 +1,7 @@
import Root from "./skeleton.svelte";
export {
Root,
//
Root as Skeleton,
};
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from '../../tools';
import type { HTMLAttributes } from 'svelte/elements';
let {
ref = $bindable(null),
class: className,
...restProps
}: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
</script>
<div
bind:this={ref}
data-slot="skeleton"
class={cn('bg-accent animate-pulse rounded-md', className)}
{...restProps}
></div>
+21
View File
@@ -0,0 +1,21 @@
import { Tooltip as TooltipPrimitive } from "bits-ui";
import Trigger from "./tooltip-trigger.svelte";
import Content from "./tooltip-content.svelte";
const Root = TooltipPrimitive.Root;
const Provider = TooltipPrimitive.Provider;
const Portal = TooltipPrimitive.Portal;
export {
Root,
Trigger,
Content,
Provider,
Portal,
//
Root as Tooltip,
Content as TooltipContent,
Trigger as TooltipTrigger,
Provider as TooltipProvider,
Portal as TooltipPortal,
};
@@ -0,0 +1,47 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from 'bits-ui';
import { cn } from '../../tools';
let {
ref = $bindable(null),
class: className,
sideOffset = 0,
side = 'top',
children,
arrowClasses,
...restProps
}: TooltipPrimitive.ContentProps & {
arrowClasses?: string;
} = $props();
</script>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
bind:ref
data-slot="tooltip-content"
{sideOffset}
{side}
class={cn(
'bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 origin-(--bits-tooltip-content-transform-origin) z-50 w-fit text-balance rounded-md px-3 py-1.5 text-xs',
className
)}
{...restProps}
>
{@render children?.()}
<TooltipPrimitive.Arrow>
{#snippet child({ props })}
<div
class={cn(
'bg-primary z-50 size-2.5 rotate-45 rounded-[2px]',
'data-[side=top]:translate-x-1/2 data-[side=top]:translate-y-[calc(-50%_+_2px)]',
'data-[side=bottom]:-translate-x-1/2 data-[side=bottom]:-translate-y-[calc(-50%_+_1px)]',
'data-[side=right]:translate-x-[calc(50%_+_2px)] data-[side=right]:translate-y-1/2',
'data-[side=left]:-translate-y-[calc(50%_-_3px)]',
arrowClasses
)}
{...props}
></div>
{/snippet}
</TooltipPrimitive.Arrow>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
@@ -0,0 +1,7 @@
<script lang="ts">
import { Tooltip as TooltipPrimitive } from "bits-ui";
let { ref = $bindable(null), ...restProps }: TooltipPrimitive.TriggerProps = $props();
</script>
<TooltipPrimitive.Trigger bind:ref data-slot="tooltip-trigger" {...restProps} />
+242
View File
@@ -0,0 +1,242 @@
# Reusable Editor Component Usage Examples
The Editor component has been rewritten to be fully reusable with configurable props instead of managing internal state.
## Basic Usage
```svelte
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content } from '@tiptap/core';
let content: Content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello, world!' }]
}
]
};
</script>
<Editor bind:content />
```
## Using HTML Content
You can also initialize the editor with HTML content by converting it to the appropriate Content format:
```svelte
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content } from '@tiptap/core';
// Example with HTML content
let htmlContent = `
<h1>Welcome to the Editor</h1>
<p>This is a <strong>paragraph</strong> with <em>formatting</em>.</p>
<ul>
<li>First item</li>
<li>Second item with <a href="https://example.com">a link</a></li>
</ul>
<blockquote>
<p>This is a quote block</p>
</blockquote>
`;
// The editor will automatically parse HTML content
let content: Content = htmlContent;
</script>
<Editor bind:content />
```
You can also set HTML content programmatically:
```svelte
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content, Editor as TiptapEditor } from '@tiptap/core';
let content: Content = { type: 'doc', content: [] };
let editor: TiptapEditor;
function loadHtmlContent() {
const htmlString = `
<h2>Dynamically Loaded Content</h2>
<p>This content was loaded after the editor was ready.</p>
<code>console.log('Hello, world!');</code>
`;
if (editor) {
editor.commands.setContent(htmlString);
}
}
function handleEditorReady(editorInstance: TiptapEditor) {
editor = editorInstance;
}
</script>
<Editor
bind:content
onEditorReady={handleEditorReady}
/>
<button on:click={loadHtmlContent}>
Load HTML Content
</button>
```
## Advanced Usage with All Props
```svelte
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content, Editor as TiptapEditor } from '@tiptap/core';
let content: Content = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'Hello, world!' }]
}
]
};
let editable = true;
let showToolBar = true;
function handleContentChange(newContent: Content) {
console.log('Content changed:', newContent);
// Handle content changes
}
function handleEditorReady(editor: TiptapEditor) {
console.log('Editor ready:', editor);
// Handle editor ready
}
</script>
<Editor
bind:content
bind:editable
{showToolBar}
enablePersistence={true}
contentStorageKey="my-app-content"
editableStorageKey="my-app-editable"
class="custom-editor-wrapper"
editorClass="custom-editor h-96"
onContentChange={handleContentChange}
onEditorReady={handleEditorReady}
/>
```
## Multiple Editors
```svelte
<script lang="ts">
import { Editor } from '@cio/ui/custom/editor';
import type { Content } from '@tiptap/core';
let article: Content = { type: 'doc', content: [] };
let notes: Content = { type: 'doc', content: [] };
</script>
<div class="grid grid-cols-2 gap-4">
<div>
<h2>Article</h2>
<Editor
bind:content={article}
contentStorageKey="article-content"
enablePersistence={true}
/>
</div>
<div>
<h2>Notes</h2>
<Editor
bind:content={notes}
showToolBar={false}
contentStorageKey="notes-content"
enablePersistence={true}
editorClass="h-48"
/>
</div>
</div>
```
## Props Reference
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `content` | `Content` | Default doc with placeholder | Editor content (bindable) |
| `showToolBar` | `boolean` | `true` | Whether to show the toolbar |
| `editable` | `boolean` | `true` | Whether the editor is editable |
| `enablePersistence` | `boolean` | `false` | Enable localStorage persistence |
| `contentStorageKey` | `string` | `'edra-content'` | localStorage key for content |
| `editableStorageKey` | `string` | `'edra-editable'` | localStorage key for editable state |
| `class` | `string` | Default wrapper classes | CSS classes for wrapper |
| `editorClass` | `string` | `'h-[32rem] overflow-auto'` | CSS classes for editor |
| `onContentChange` | `(content: Content) => void` | `undefined` | Callback when content changes |
| `onEditorReady` | `(editor: Editor) => void` | `undefined` | Callback when editor is ready |
| `onEditorDestroy` | `() => void` | `undefined` | Callback when editor is destroyed |
## Callbacks
| Callback | Type | Description |
|----------|------|-------------|
| `onContentChange` | `(content: Content) => void` | Called when editor content changes |
| `onEditorReady` | `(editor: Editor) => void` | Called when editor is ready |
| `onEditorDestroy` | `() => void` | Called when editor is destroyed |
```svelte
<Editor
content={myContent}
onContentChange={(content) => {
console.log('New content:', content);
// Save to database, update state, etc.
}}
onEditorReady={(editor) => {
console.log('Editor is ready, you can focus it:', editor);
editor.commands.focus();
}}
/>
```
## Migration from Old Version
### Before (internal state)
```svelte
<script>
import { Editor } from './editor.svelte';
// No props needed, everything was internal
</script>
<Editor />
```
### After (prop-based)
```svelte
<script>
import { Editor } from './editor.svelte';
import type { Content } from '@tiptap/core';
let content: Content = { type: 'doc', content: [] };
</script>
<Editor bind:content />
```
## Benefits of the Rewrite
1. **Reusability**: Can be used multiple times with different configurations
2. **State Control**: Parent components control the state
3. **Flexibility**: Persistence can be disabled or customized
4. **Callback Handling**: Modern callback props instead of deprecated events
5. **TypeScript Support**: Full type safety with proper interfaces
6. **Customization**: All styling can be overridden via props
@@ -0,0 +1 @@
export { default as ToolBarCommands } from './toolbar-commands';
@@ -0,0 +1,404 @@
import AlighJustify from '@lucide/svelte/icons/align-justify';
import AlignCenter from '@lucide/svelte/icons/align-center';
import AlignLeft from '@lucide/svelte/icons/align-left';
import AlignRight from '@lucide/svelte/icons/align-right';
import Audio from '@lucide/svelte/icons/audio-lines';
import Bold from '@lucide/svelte/icons/bold';
import Code from '@lucide/svelte/icons/code';
import type { EdraToolBarCommands } from './types';
import Heading1 from '@lucide/svelte/icons/heading-1';
import Heading2 from '@lucide/svelte/icons/heading-2';
import Heading3 from '@lucide/svelte/icons/heading-3';
import Heading4 from '@lucide/svelte/icons/heading-4';
import IFrame from '@lucide/svelte/icons/code-xml';
import Image from '@lucide/svelte/icons/image';
import Italic from '@lucide/svelte/icons/italic';
import Link from '@lucide/svelte/icons/link-2';
import List from '@lucide/svelte/icons/list';
import ListChecks from '@lucide/svelte/icons/list-checks';
import ListOrdered from '@lucide/svelte/icons/list-ordered';
import Quote from '@lucide/svelte/icons/quote';
import Redo from '@lucide/svelte/icons/redo-2';
import StrikeThrough from '@lucide/svelte/icons/strikethrough';
import Subscript from '@lucide/svelte/icons/subscript';
import Superscript from '@lucide/svelte/icons/superscript';
import Table from '@lucide/svelte/icons/table';
import Underline from '@lucide/svelte/icons/underline';
import Undo from '@lucide/svelte/icons/undo-2';
import Video from '@lucide/svelte/icons/video';
import { isMac } from '../utils';
const commands: Record<string, EdraToolBarCommands[]> = {
'undo-redo': [
{
icon: Undo,
name: 'undo',
tooltip: 'Undo',
shortCut: `${isMac ? '⌘' : 'Ctrl+'}Z`,
onClick: (editor) => {
editor.chain().focus().undo().run();
},
clickable: (editor) => {
return editor.can().undo();
}
},
{
icon: Redo,
name: 'redo',
tooltip: 'Redo',
shortCut: `${isMac ? '⌘' : 'Ctrl+'}Y`,
onClick: (editor) => {
editor.chain().focus().redo().run();
},
clickable: (editor) => {
return editor.can().redo();
}
}
],
headings: [
{
icon: Heading1,
name: 'h1',
tooltip: 'Heading 1',
shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}1`,
onClick: (editor) => {
editor.chain().focus().toggleHeading({ level: 1 }).run();
},
clickable: (editor) => {
return editor.can().toggleHeading({ level: 1 });
},
isActive: (editor) => {
return editor.isActive('heading', { level: 1 });
}
},
{
icon: Heading2,
name: 'h2',
tooltip: 'Heading 2',
shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}2`,
onClick: (editor) => {
editor.chain().focus().toggleHeading({ level: 2 }).run();
},
clickable: (editor) => {
return editor.can().toggleHeading({ level: 2 });
},
isActive: (editor) => {
return editor.isActive('heading', { level: 2 });
}
},
{
icon: Heading3,
name: 'h3',
tooltip: 'Heading 3',
shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}3`,
onClick: (editor) => {
editor.chain().focus().toggleHeading({ level: 3 }).run();
},
clickable: (editor) => {
return editor.can().toggleHeading({ level: 3 });
},
isActive: (editor) => {
return editor.isActive('heading', { level: 3 });
}
},
{
icon: Heading4,
name: 'h4',
tooltip: 'Heading 4',
shortCut: `${isMac ? '⌘⌥' : 'Ctrl+Alt+'}4`,
onClick: (editor) => {
editor.chain().focus().toggleHeading({ level: 4 }).run();
},
clickable: (editor) => {
return editor.can().toggleHeading({ level: 4 });
},
isActive: (editor) => {
return editor.isActive('heading', { level: 4 });
}
}
],
'text-formatting': [
{
icon: Link,
name: 'link',
tooltip: 'Link',
onClick: (editor) => {
if (editor.isActive('link')) {
editor.chain().focus().unsetLink().run();
} else {
const url = window.prompt('Enter the URL of the link:');
if (url) {
editor.chain().focus().toggleLink({ href: url }).run();
}
}
},
isActive: (editor) => {
return editor.isActive('link');
}
},
{
icon: Bold,
name: 'bold',
tooltip: 'Bold',
shortCut: `${isMac ? '⌘' : 'Ctrl+'}B`,
onClick: (editor) => {
editor.chain().focus().toggleBold().run();
},
clickable: (editor) => {
return editor.can().toggleBold();
},
isActive: (editor) => {
return editor.isActive('bold');
}
},
{
icon: Italic,
name: 'italic',
tooltip: 'Italic',
shortCut: `${isMac ? '⌘' : 'Ctrl+'}I`,
onClick: (editor) => {
editor.chain().focus().toggleItalic().run();
},
clickable: (editor) => {
return editor.can().toggleItalic();
},
isActive: (editor) => {
return editor.isActive('italic');
}
},
{
icon: Underline,
name: 'underline',
tooltip: 'Underline',
shortCut: `${isMac ? '⌘' : 'Ctrl+'}U`,
onClick: (editor) => {
editor.chain().focus().toggleUnderline().run();
},
clickable: (editor) => {
return editor.can().toggleUnderline();
},
isActive: (editor) => {
return editor.isActive('underline');
}
},
{
icon: StrikeThrough,
name: 'strikethrough',
tooltip: 'Strikethrough',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}S`,
onClick: (editor) => {
editor.chain().focus().toggleStrike().run();
},
clickable: (editor) => {
return editor.can().toggleStrike();
},
isActive: (editor) => {
return editor.isActive('strike');
}
},
{
icon: Quote,
name: 'blockQuote',
tooltip: 'BlockQuote',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}B`,
onClick: (editor) => {
editor.chain().focus().toggleBlockquote().run();
},
clickable: (editor) => {
return editor.can().toggleBlockquote();
},
isActive: (editor) => {
return editor.isActive('blockquote');
}
},
{
icon: Code,
name: 'code',
tooltip: 'Inline Code',
shortCut: `${isMac ? '⌘' : 'Ctrl+'}E`,
onClick: (editor) => {
editor.chain().focus().toggleCode().run();
},
clickable: (editor) => {
return editor.can().toggleCode();
},
isActive: (editor) => {
return editor.isActive('code');
}
},
{
icon: Superscript,
name: 'superscript',
tooltip: 'Superscript',
shortCut: `${isMac ? '⌘' : 'Ctrl+'}.`,
onClick: (editor) => {
editor.chain().focus().toggleSuperscript().run();
},
clickable: (editor) => {
return editor.can().toggleSuperscript();
},
isActive: (editor) => {
return editor.isActive('superscript');
}
},
{
icon: Subscript,
name: 'subscript',
tooltip: 'Subscript',
shortCut: `${isMac ? '⌘' : 'Ctrl+'},`,
onClick: (editor) => {
editor.chain().focus().toggleSubscript().run();
},
clickable: (editor) => {
return editor.can().toggleSubscript();
},
isActive: (editor) => {
return editor.isActive('subscript');
}
}
],
alignment: [
{
icon: AlignLeft,
name: 'align-left',
tooltip: 'Align Left',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}L`,
onClick: (editor) => {
editor.chain().focus().toggleTextAlign('left').run();
},
clickable: (editor) => {
return editor.can().toggleTextAlign('left');
},
isActive: (editor) => editor.isActive({ textAlign: 'left' })
},
{
icon: AlignCenter,
name: 'align-center',
tooltip: 'Align Center',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}E`,
onClick: (editor) => {
editor.chain().focus().toggleTextAlign('center').run();
},
clickable: (editor) => {
return editor.can().toggleTextAlign('center');
},
isActive: (editor) => editor.isActive({ textAlign: 'center' })
},
{
icon: AlignRight,
name: 'align-right',
tooltip: 'Align Right',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}R`,
onClick: (editor) => {
editor.chain().focus().toggleTextAlign('right').run();
},
clickable: (editor) => {
return editor.can().toggleTextAlign('right');
},
isActive: (editor) => editor.isActive({ textAlign: 'right' })
},
{
icon: AlighJustify,
name: 'align-justify',
tooltip: 'Align Justify',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}J`,
onClick: (editor) => {
editor.chain().focus().toggleTextAlign('justify').run();
},
clickable: (editor) => {
return editor.can().toggleTextAlign('justify');
},
isActive: (editor) => editor.isActive({ textAlign: 'justify' })
}
],
lists: [
{
icon: List,
name: 'bulletList',
tooltip: 'Bullet List',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}8`,
onClick: (editor) => {
editor.chain().focus().toggleBulletList().run();
},
isActive: (editor) => editor.isActive('bulletList')
},
{
icon: ListOrdered,
name: 'orderedList',
tooltip: 'Ordered List',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}7`,
onClick: (editor) => {
editor.chain().focus().toggleOrderedList().run();
},
isActive: (editor) => editor.isActive('orderedList')
},
{
icon: ListChecks,
name: 'taskList',
tooltip: 'Task List',
shortCut: `${isMac ? '⌘⇧' : 'Ctrl+Shift+'}9`,
onClick: (editor) => {
editor.chain().focus().toggleTaskList().run();
},
isActive: (editor) => editor.isActive('taskList')
}
],
media: [
{
icon: Image,
name: 'image-placeholder',
tooltip: 'Image Placeholder',
onClick: (editor) => {
editor.chain().focus().insertImagePlaceholder().run();
},
isActive: (editor) => editor.isActive('image-placeholder')
},
{
icon: Video,
name: 'video-placeholder',
tooltip: 'Video Placeholder',
onClick: (editor) => {
editor.chain().focus().insertVideoPlaceholder().run();
},
isActive: (editor) => editor.isActive('video-placeholder')
},
{
icon: Audio,
name: 'audio-placeholder',
tooltip: 'Audio Placeholder',
onClick: (editor) => {
editor.chain().focus().insertAudioPlaceholder().run();
},
isActive: (editor) => editor.isActive('audio-placeholder')
},
{
icon: IFrame,
name: 'iframe-placeholder',
tooltip: 'IFrame Placeholder',
onClick: (editor) => {
editor.chain().focus().insertIFramePlaceholder().run();
},
isActive: (editor) => editor.isActive('iframe-placeholder')
}
],
table: [
{
icon: Table,
name: 'table',
tooltip: 'Table',
onClick: (editor) => {
if (editor.isActive('table')) {
const del = confirm('Do you really want to delete this table??');
if (del) {
editor.chain().focus().deleteTable().run();
return;
}
}
editor.chain().focus().insertTable({ cols: 3, rows: 3, withHeaderRow: false }).run();
},
isActive: (editor) => editor.isActive('table')
}
]
};
export default commands;
@@ -0,0 +1,12 @@
import type { Editor } from '@tiptap/core';
import { Icon } from '@lucide/svelte';
export interface EdraToolBarCommands {
name: string;
icon: typeof Icon;
tooltip?: string;
shortCut?: string;
onClick?: (editor: Editor) => void;
isActive?: (editor: Editor) => boolean;
clickable?: (editor: Editor) => boolean;
}
@@ -0,0 +1,67 @@
<script lang="ts">
import { onMount, type Snippet } from 'svelte';
import { BubbleMenuPlugin, type BubbleMenuPluginProps } from '@tiptap/extension-bubble-menu';
import type { Editor } from '@tiptap/core';
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
interface Props
extends Optional<Omit<Optional<BubbleMenuPluginProps, 'pluginKey'>, 'element'>, 'editor'> {
editor?: Editor;
children?: Snippet<[]>;
class?: string;
style?: string;
pluginKey?: string;
updateDelay?: number;
resizeDelay?: number;
}
let {
editor,
shouldShow = null,
class: className = '',
style = '',
children,
updateDelay,
resizeDelay,
pluginKey = 'bubbleMenu',
options,
...restProps
}: Props = $props();
let element = $state<HTMLElement>();
onMount(() => {
if (!element) return;
element.style.position = 'absolute';
element.style.visibility = 'hidden';
if (!editor || editor.isDestroyed) {
console.warn('BubbleMenu component does not have editor prop or editor is destroyed.');
return;
}
const plugin = BubbleMenuPlugin({
pluginKey,
editor,
element,
shouldShow,
updateDelay,
resizeDelay,
options
});
editor.registerPlugin(plugin);
return () => {
if (editor && !editor.isDestroyed) {
editor.unregisterPlugin(pluginKey);
}
};
});
</script>
<div bind:this={element} class={`bubble-menu-wrapper ${className}`} {style} {...restProps}>
{@render children?.()}
</div>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Editor } from '@tiptap/core';
import { onMount } from 'svelte';
import GripVertical from '@lucide/svelte/icons/grip-vertical';
import { DragHandlePlugin } from '../extensions/drag-handle';
interface Props {
editor: Editor;
}
const { editor }: Props = $props();
const pluginKey = 'globalDragHandle';
onMount(() => {
const plugin = DragHandlePlugin({
pluginKey: pluginKey,
dragHandleWidth: 20,
scrollTreshold: 100,
dragHandleSelector: '.drag-handle',
excludedTags: ['pre', 'code', 'table p'],
customNodes: []
});
editor.registerPlugin(plugin);
return () => editor.unregisterPlugin(pluginKey);
});
</script>
<div class="drag-handle">
<GripVertical />
</div>
@@ -0,0 +1,30 @@
<script lang="ts">
import type { Icon } from '@lucide/svelte';
import type { Snippet } from 'svelte';
import { NodeViewWrapper } from 'svelte-tiptap';
interface Props {
icon?: typeof Icon;
title?: string;
onClick?: () => void;
class?: string;
children?: Snippet<[]>;
}
const { icon, title, onClick, class: className = '', children }: Props = $props();
</script>
<NodeViewWrapper
as="div"
contenteditable="false"
class={`media-placeholder ${className}`}
onclick={onClick}
style="user-select: none;"
>
{#if !children && icon && title}
{@const Icon = icon}
<Icon />
<div contenteditable="false">{title}</div>
{/if}
{@render children?.()}
</NodeViewWrapper>
+454
View File
@@ -0,0 +1,454 @@
/* Base TipTap Editor Styles with Light/Dark Theme Support */
.tiptap :first-child {
margin-top: 0;
}
/* For Placeholder */
.tiptap .is-empty::before {
pointer-events: none;
float: left;
height: 0;
color: var(--border-color-hover);
content: attr(data-placeholder);
}
/* Heading Styles */
.tiptap h1,
.tiptap h2,
.tiptap h3,
.tiptap h4,
.tiptap h5,
.tiptap h6 {
line-height: 1.2;
margin-top: 1rem;
text-wrap: pretty;
}
.tiptap h1,
.tiptap h2 {
margin-top: 1rem;
margin-bottom: 1rem;
}
.tiptap h1 {
scroll-margin: 5rem;
font-size: 2.25rem;
line-height: 2.5rem;
font-weight: 800;
letter-spacing: -0.015em;
}
.tiptap h2 {
font-size: 1.5rem;
font-weight: 700;
}
.tiptap h3 {
font-size: 1.25rem;
font-weight: 600;
}
.tiptap h4,
.tiptap h5,
.tiptap h6 {
font-size: 1rem;
font-weight: 600;
}
.tiptap blockquote {
margin: 1rem 0rem;
padding: 0.5rem 0;
padding-left: 0.75rem;
font-style: italic;
color: var(--blockquote-color);
position: relative;
}
.tiptap blockquote::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 0.25rem;
background-color: var(--blockquote-border);
border-radius: 0.5rem;
}
.tiptap blockquote p {
margin: 0;
}
/* Horizontal Rule */
.tiptap hr {
border: none;
border-top: 1px solid var(--border-color);
margin: 1rem 0;
}
/* Inline Code */
.tiptap code:not(pre code) {
background-color: var(--code-bg);
border-radius: 0.25rem;
padding: 0.2rem 0.3rem;
font-family: monospace;
font-size: 0.875rem;
font-weight: bold;
}
/* List Styling */
.tiptap ul,
.tiptap ol {
padding: 0 1rem;
margin: 0.5rem 1rem 0.5rem 0.4rem;
}
.tiptap ul li p,
.tiptap ol li p {
margin-top: 0.25rem;
margin-bottom: 0.25rem;
}
/* Task List Styling */
.tiptap ul[data-type='taskList'] {
list-style: none;
margin: 0;
padding: 0;
}
.tiptap ul[data-type='taskList'] li {
align-items: flex-start;
display: flex;
}
.tiptap ul[data-type='taskList'] li > label {
flex: 0 0 auto;
margin-right: 0.5rem;
user-select: none;
}
.tiptap ul[data-type='taskList'] li > div {
flex: 1 1 auto;
}
.tiptap ul[data-type='taskList'] input[type='checkbox'] {
cursor: pointer;
}
.tiptap ul[data-type='taskList'] ul[data-type='taskList'] {
margin: 0;
}
ul[data-type='taskList'] li[data-checked='true'] div {
color: var(--task-completed-color);
text-decoration: line-through;
}
input[type='checkbox'] {
position: relative;
top: 0.25rem;
margin: 0;
display: grid;
place-content: center;
cursor: pointer;
width: 1rem;
height: 1rem;
border-radius: 0.25rem;
}
/* Color Swatches */
.color {
white-space: nowrap;
}
.color::before {
margin-bottom: 0.15rem;
margin-right: 0.1rem;
display: inline-block;
width: 1rem;
height: 1rem;
border-radius: 0.25rem;
border: 1px solid var(--border-color);
vertical-align: middle;
background-color: var(--color);
content: ' ';
}
/* Code Block Styling */
.tiptap pre {
margin: 0;
display: flex;
height: fit-content;
overflow: auto;
background-color: transparent;
padding: 0;
}
.tiptap pre code {
flex: 1;
border-radius: 0 !important;
background-color: transparent;
padding: 0;
color: inherit;
font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono';
}
/* Drag Handle Styling */
.drag-handle {
position: fixed;
z-index: 50;
width: 1.5rem;
height: 1.5rem;
display: flex;
padding-right: 0.5rem;
flex-direction: column;
justify-content: center;
align-items: center;
cursor: grab;
opacity: 100;
transition-property: opacity;
transition-duration: 200ms;
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
color: var(--border-color-hover);
}
.drag-handle:active {
cursor: grabbing;
}
.drag-handle.hide {
opacity: 0;
pointer-events: none;
}
@media screen and (max-width: 600px) {
.drag-handle {
display: none;
pointer-events: none;
}
}
.drag-handle svg {
height: 1rem;
width: 1rem;
}
/* Math Equations (KaTeX) */
.katex:hover {
background-color: var(--code-bg);
}
.katex.result {
border-bottom: 1px dashed var(--highlight-border);
background-color: var(--highlight-color);
}
/* Table Styling */
.ProseMirror .tableWrapper {
margin: 0;
overflow: auto;
padding: 1rem;
}
.ProseMirror table {
margin-top: 1rem;
margin-bottom: 1rem;
box-sizing: border-box;
width: 100%;
border-collapse: collapse;
border-radius: 0.25rem;
border: 1px solid var(--table-border);
}
.ProseMirror table td,
.ProseMirror table th {
position: relative;
min-width: 100px;
border: 1px solid var(--table-border);
padding: 0.5rem;
text-align: left;
vertical-align: top;
}
.ProseMirror table td:first-of-type:not(a),
.ProseMirror table th:first-of-type:not(a) {
margin-top: 0;
}
.ProseMirror table td p,
.ProseMirror table th p {
margin: 0;
}
.ProseMirror table td p + p,
.ProseMirror table th p + p {
margin-top: 0.75rem;
}
.ProseMirror table th {
font-weight: bold;
}
.ProseMirror table .column-resize-handle {
pointer-events: none;
position: absolute;
top: 0;
right: -0.25rem;
bottom: -2px;
display: flex;
width: 0.5rem;
background-color: var(--table-border);
}
.ProseMirror table .column-resize-handle::before {
content: '';
margin-left: 0.5rem;
height: 100%;
width: 1px;
background-color: var(--table-border);
}
.ProseMirror table .selectedCell {
border-style: double;
border-color: var(--table-border);
background-color: var(--table-bg-selected);
}
.ProseMirror table .grip-column,
.ProseMirror table .grip-row {
position: absolute;
z-index: 10;
display: flex;
cursor: pointer;
align-items: center;
justify-content: center;
background-color: var(--table-bg-selected);
}
.ProseMirror table .grip-column {
top: -0.75rem;
left: 0;
margin-left: -1px;
height: 0.75rem;
width: calc(100% + 1px);
border-left: 1px solid var(--table-border);
}
.ProseMirror table .grip-column:hover::before,
.ProseMirror table .grip-column.selected::before {
content: '';
width: 0.625rem;
}
.ProseMirror table .grip-column:hover {
background-color: var(--table-bg-hover);
}
.ProseMirror table .grip-column:hover::before {
border-bottom: 2px dotted var(--border-color-hover);
}
.ProseMirror table .grip-column.first {
border-top-left-radius: 0.125rem;
border-color: transparent;
}
.ProseMirror table .grip-column.last {
border-top-right-radius: 0.125rem;
}
.ProseMirror table .grip-column.selected {
border-color: var(--table-border);
background-color: var(--table-bg-hover);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.ProseMirror table .grip-column.selected::before {
border-bottom: 2px dotted var(--border-color-hover);
}
.ProseMirror table .grip-row {
left: -0.75rem;
top: 0;
margin-top: -1px;
height: calc(100% + 1px);
width: 0.75rem;
border-top: 1px solid var(--table-border);
}
.ProseMirror table .grip-row:hover::before,
.ProseMirror table .grip-row.selected::before {
content: '';
height: 0.625rem;
}
.ProseMirror table .grip-row:hover {
background-color: var(--table-bg-hover);
}
.ProseMirror table .grip-row:hover::before {
border-left: 2px dotted var(--border-color-hover);
}
.ProseMirror table .grip-row.first {
border-top-left-radius: 0.125rem;
border-color: transparent;
}
.ProseMirror table .grip-row.last {
border-bottom-left-radius: 0.125rem;
}
.ProseMirror table .grip-row.selected {
border-color: var(--table-border);
background-color: var(--table-bg-hover);
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
.ProseMirror table .grip-row.selected::before {
border-left: 2px dotted var(--border-color-hover);
}
.tiptap .search-result {
background-color: var(--search-result-bg);
color: black;
}
.tiptap .search-result-current {
background-color: var(--search-result-current-bg);
color: black;
}
.code-wrapper {
background-color: var(--codeblock-bg);
border-radius: 0.5rem;
padding: 1rem;
position: relative;
height: fit-content;
width: 100%;
margin: 0.5rem 0;
}
.code-wrapper-tile {
opacity: 0;
width: 100%;
display: flex;
position: absolute;
top: 0;
padding: 0.25rem;
right: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
transition: opacity 0.2s ease-in-out;
}
.code-wrapper:hover .code-wrapper-tile {
opacity: 1;
}
.tiptap iframe {
aspect-ratio: 16 / 9;
}
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import type { Content, Editor } from '@tiptap/core';
import type { Transaction } from '@tiptap/pm/state';
import { EdraEditor, EdraToolBar, EdraBubbleMenu, EdraDragHandleExtended } from './ui';
import { slide } from 'svelte/transition';
import { cn } from '$src/tools';
interface Props {
// Content of the editor
content?: Content;
// Whether the toolbar should be visible
showToolBar?: boolean;
// Whether the editor is editable
editable?: boolean;
// Whether to enable localStorage persistence
enablePersistence?: boolean;
// localStorage key for content persistence
contentStorageKey?: string;
// localStorage key for editable state persistence
editableStorageKey?: string;
// CSS class for the editor wrapper
class?: string;
// CSS class for the editor itself
editorClass?: string;
// Callback functions
onContentChange?: (content: Content) => void;
onEditorReady?: (editor: Editor) => void;
onEditorDestroy?: () => void;
}
let {
content = $bindable(getDefaultContent()),
showToolBar = true,
editable = true,
enablePersistence = false,
contentStorageKey = 'edra-content',
editableStorageKey = 'edra-editable',
class: className = '',
editorClass = '',
onContentChange,
onEditorReady,
onEditorDestroy
}: Props = $props();
let editor = $state<Editor>();
// Browser detection
const browser = typeof window !== 'undefined';
// Default content fallback
function getDefaultContent(): Content {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'Start typing...'
}
]
}
]
};
}
// Handle content persistence
$effect(() => {
if (enablePersistence && browser && content) {
localStorage.setItem(contentStorageKey, JSON.stringify(content));
}
});
// Handle editable state persistence
$effect(() => {
if (enablePersistence && browser) {
localStorage.setItem(editableStorageKey, editable.toString());
}
});
// Load persisted content and editable state on mount
$effect(() => {
if (enablePersistence && browser) {
try {
// Load content
const rawContentString = localStorage.getItem(contentStorageKey);
if (rawContentString !== null) {
const persistedContent: Content = JSON.parse(rawContentString);
console.log('persistedContent', persistedContent);
content = persistedContent;
}
// Load editable state
const rawEditableString = localStorage.getItem(editableStorageKey);
if (rawEditableString !== null) {
editable = rawEditableString === 'true';
}
} catch (error) {
console.warn('Failed to load persisted state:', error);
}
}
});
// Handle editor ready
$effect(() => {
if (editor) {
onEditorReady?.(editor);
}
});
function onUpdate(props: { editor: Editor; transaction: Transaction }) {
if (props?.editor && !props.editor.isDestroyed) {
const newContent = props.editor.getJSON();
content = newContent;
onContentChange?.(newContent);
}
}
</script>
{#if browser}
<div
class={cn(
'bg-background z-50 mx-auto mt-12 flex size-full w-[95%] max-w-5xl flex-col rounded-md border border-dashed sm:w-[85%]',
className
)}
>
{#if editor && !editor.isDestroyed}
{#if showToolBar}
<div transition:slide>
<EdraToolBar
class="bg-secondary/50 flex w-full items-center overflow-x-auto border-b border-dashed p-0.5"
{editor}
/>
</div>
{/if}
<EdraBubbleMenu {editor} />
<EdraDragHandleExtended {editor} />
{/if}
<EdraEditor
class={cn('h-[32rem] overflow-auto p-4', editorClass)}
bind:editor
{editable}
content={content || getDefaultContent()}
{onUpdate}
/>
</div>
{/if}
+110
View File
@@ -0,0 +1,110 @@
import { Editor, type Extensions, type EditorOptions, type Content } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { getHandlePaste } from './utils';
import Subscript from '@tiptap/extension-subscript';
import Superscript from '@tiptap/extension-superscript';
import Typography from '@tiptap/extension-typography';
import { ColorHighlighter } from './extensions/ColorHighlighter';
import { FontSize, TextStyle, Color } from '@tiptap/extension-text-style';
import TextAlign from '@tiptap/extension-text-align';
import Highlight from '@tiptap/extension-highlight';
import SearchAndReplace from './extensions/FindAndReplace';
import { TaskItem, TaskList } from '@tiptap/extension-list';
import { Table, TableCell, TableRow, TableHeader } from './extensions/table';
import { Placeholder } from '@tiptap/extensions';
import { Markdown } from 'tiptap-markdown';
import MathExtension from '@aarkue/tiptap-math-extension';
import AutoJoiner from 'tiptap-extension-auto-joiner';
import 'katex/dist/katex.min.css';
export default (
element?: HTMLElement,
content?: Content,
extensions?: Extensions,
options?: Partial<EditorOptions>
) => {
const editor = new Editor({
element,
content,
extensions: [
StarterKit.configure({
orderedList: {
HTMLAttributes: {
class: 'list-decimal'
}
},
bulletList: {
HTMLAttributes: {
class: 'list-disc'
}
},
heading: {
levels: [1, 2, 3, 4]
},
link: {
openOnClick: false,
autolink: true,
linkOnPaste: true
},
codeBlock: false
}),
Highlight.configure({
multicolor: true
}),
Placeholder.configure({
emptyEditorClass: 'is-empty',
// Use a placeholder:
// Use different placeholders depending on the node type:
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
return 'What’s the title?';
} else if (node.type.name === 'paragraph') {
return 'Press / or write something ...';
}
return '';
}
}),
Color,
Subscript,
Superscript,
Typography,
ColorHighlighter,
TextStyle,
FontSize,
TextAlign.configure({
types: ['heading', 'paragraph']
}),
TaskList,
TaskItem.configure({
nested: true
}),
SearchAndReplace,
MathExtension.configure({ evaluation: true }),
AutoJoiner,
Table,
TableHeader,
TableRow,
TableCell,
Markdown.configure({
html: true,
tightLists: true,
tightListClass: 'tight',
bulletListMarker: '-',
linkify: true,
breaks: true,
transformPastedText: true,
transformCopiedText: false
}),
...(extensions ?? [])
],
...options
});
editor.setOptions({
editorProps: {
handlePaste: getHandlePaste(editor)
}
});
return editor;
};
@@ -0,0 +1,27 @@
import { Extension } from '@tiptap/core';
import { Plugin } from '@tiptap/pm/state';
import { findColors } from '../utils';
export const ColorHighlighter = Extension.create({
name: 'colorHighlighter',
addProseMirrorPlugins() {
return [
new Plugin({
state: {
init(_, { doc }) {
return findColors(doc);
},
apply(transaction, oldState) {
return transaction.docChanged ? findColors(transaction.doc) : oldState;
}
},
props: {
decorations(state) {
return this.getState(state);
}
}
})
];
}
});
@@ -0,0 +1,408 @@
// MIT License
// Copyright (c) 2023 - 2024 Jeet Mandaliya (Github Username: sereneinserenade)
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import { Extension, type Range, type Dispatch } from '@tiptap/core';
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state';
import { Node as PMNode } from '@tiptap/pm/model';
declare module '@tiptap/core' {
interface Commands<ReturnType> {
search: {
/**
* @description Set search term in extension.
*/
setSearchTerm: (searchTerm: string) => ReturnType;
/**
* @description Set replace term in extension.
*/
setReplaceTerm: (replaceTerm: string) => ReturnType;
/**
* @description Set case sensitivity in extension.
*/
setCaseSensitive: (caseSensitive: boolean) => ReturnType;
/**
* @description Reset current search result to first instance.
*/
resetIndex: () => ReturnType;
/**
* @description Find next instance of search result.
*/
nextSearchResult: () => ReturnType;
/**
* @description Find previous instance of search result.
*/
previousSearchResult: () => ReturnType;
/**
* @description Replace first instance of search result with given replace term.
*/
replace: () => ReturnType;
/**
* @description Replace all instances of search result with given replace term.
*/
replaceAll: () => ReturnType;
};
}
}
interface TextNodesWithPosition {
text: string;
pos: number;
}
const getRegex = (s: string, disableRegex: boolean, caseSensitive: boolean): RegExp => {
return RegExp(
disableRegex ? s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') : s,
caseSensitive ? 'gu' : 'gui'
);
};
interface ProcessedSearches {
decorationsToReturn: DecorationSet;
results: Range[];
}
function processSearches(
doc: PMNode,
searchTerm: RegExp,
searchResultClass: string,
resultIndex: number
): ProcessedSearches {
const decorations: Decoration[] = [];
const results: Range[] = [];
let textNodesWithPosition: TextNodesWithPosition[] = [];
let index = 0;
if (!searchTerm) {
return {
decorationsToReturn: DecorationSet.empty,
results: []
};
}
doc?.descendants((node, pos) => {
if (node.isText) {
if (textNodesWithPosition[index]) {
textNodesWithPosition[index] = {
text: textNodesWithPosition[index].text + node.text,
pos: textNodesWithPosition[index].pos
};
} else {
textNodesWithPosition[index] = {
text: `${node.text}`,
pos
};
}
} else {
index += 1;
}
});
textNodesWithPosition = textNodesWithPosition.filter(Boolean);
for (const element of textNodesWithPosition) {
const { text, pos } = element;
const matches = Array.from(text.matchAll(searchTerm)).filter(([matchText]) => matchText.trim());
for (const m of matches) {
if (m[0] === '') break;
if (m.index !== undefined) {
results.push({
from: pos + m.index,
to: pos + m.index + m[0].length
});
}
}
}
for (let i = 0; i < results.length; i += 1) {
const r = results[i];
const className =
i === resultIndex ? `${searchResultClass} ${searchResultClass}-current` : searchResultClass;
const decoration: Decoration = Decoration.inline(r.from, r.to, {
class: className
});
decorations.push(decoration);
}
return {
decorationsToReturn: DecorationSet.create(doc, decorations),
results
};
}
const replace = (
replaceTerm: string,
results: Range[],
{ state, dispatch }: { state: EditorState; dispatch: Dispatch }
) => {
const firstResult = results[0];
if (!firstResult) return;
const { from, to } = results[0];
if (dispatch) dispatch(state.tr.insertText(replaceTerm, from, to));
};
const rebaseNextResult = (
replaceTerm: string,
index: number,
lastOffset: number,
results: Range[]
): [number, Range[]] | null => {
const nextIndex = index + 1;
if (!results[nextIndex]) return null;
const { from: currentFrom, to: currentTo } = results[index];
const offset = currentTo - currentFrom - replaceTerm.length + lastOffset;
const { from, to } = results[nextIndex];
results[nextIndex] = {
to: to - offset,
from: from - offset
};
return [offset, results];
};
const replaceAll = (
replaceTerm: string,
results: Range[],
{ tr, dispatch }: { tr: Transaction; dispatch: Dispatch }
) => {
let offset = 0;
let resultsCopy = results.slice();
if (!resultsCopy.length) return;
for (let i = 0; i < resultsCopy.length; i += 1) {
const { from, to } = resultsCopy[i];
tr.insertText(replaceTerm, from, to);
const rebaseNextResultResponse = rebaseNextResult(replaceTerm, i, offset, resultsCopy);
if (!rebaseNextResultResponse) continue;
offset = rebaseNextResultResponse[0];
resultsCopy = rebaseNextResultResponse[1];
}
if (dispatch) {
dispatch(tr);
}
};
export const searchAndReplacePluginKey = new PluginKey('searchAndReplacePlugin');
export interface SearchAndReplaceOptions {
searchResultClass: string;
disableRegex: boolean;
}
export interface SearchAndReplaceStorage {
searchTerm: string;
replaceTerm: string;
results: Range[];
lastSearchTerm: string;
caseSensitive: boolean;
lastCaseSensitive: boolean;
resultIndex: number;
lastResultIndex: number;
}
export const SearchAndReplace = Extension.create<SearchAndReplaceOptions, SearchAndReplaceStorage>({
name: 'searchAndReplace',
addOptions() {
return {
searchResultClass: 'search-result',
disableRegex: true
};
},
addStorage() {
return {
searchTerm: '',
replaceTerm: '',
results: [],
lastSearchTerm: '',
caseSensitive: false,
lastCaseSensitive: false,
resultIndex: 0,
lastResultIndex: 0
};
},
addCommands() {
return {
setSearchTerm:
(searchTerm: string) =>
({ editor }) => {
editor.storage.searchAndReplace.searchTerm = searchTerm;
return false;
},
setReplaceTerm:
(replaceTerm: string) =>
({ editor }) => {
editor.storage.searchAndReplace.replaceTerm = replaceTerm;
return false;
},
setCaseSensitive:
(caseSensitive: boolean) =>
({ editor }) => {
editor.storage.searchAndReplace.caseSensitive = caseSensitive;
return false;
},
resetIndex:
() =>
({ editor }) => {
editor.storage.searchAndReplace.resultIndex = 0;
return false;
},
nextSearchResult:
() =>
({ editor }) => {
const { results, resultIndex } = editor.storage.searchAndReplace;
const nextIndex = resultIndex + 1;
if (results[nextIndex]) {
editor.storage.searchAndReplace.resultIndex = nextIndex;
} else {
editor.storage.searchAndReplace.resultIndex = 0;
}
return false;
},
previousSearchResult:
() =>
({ editor }) => {
const { results, resultIndex } = editor.storage.searchAndReplace;
const prevIndex = resultIndex - 1;
if (results[prevIndex]) {
editor.storage.searchAndReplace.resultIndex = prevIndex;
} else {
editor.storage.searchAndReplace.resultIndex = results.length - 1;
}
return false;
},
replace:
() =>
({ editor, state, dispatch }) => {
const { replaceTerm, results } = editor.storage.searchAndReplace;
replace(replaceTerm, results, { state, dispatch });
return false;
},
replaceAll:
() =>
({ editor, tr, dispatch }) => {
const { replaceTerm, results } = editor.storage.searchAndReplace;
replaceAll(replaceTerm, results, { tr, dispatch });
return false;
}
};
},
addProseMirrorPlugins() {
const editor = this.editor;
const { searchResultClass, disableRegex } = this.options;
const setLastSearchTerm = (t: string) => (editor.storage.searchAndReplace.lastSearchTerm = t);
const setLastCaseSensitive = (t: boolean) =>
(editor.storage.searchAndReplace.lastCaseSensitive = t);
const setLastResultIndex = (t: number) => (editor.storage.searchAndReplace.lastResultIndex = t);
return [
new Plugin({
key: searchAndReplacePluginKey,
state: {
init: () => DecorationSet.empty,
apply({ doc, docChanged }, oldState) {
const {
searchTerm,
lastSearchTerm,
caseSensitive,
lastCaseSensitive,
resultIndex,
lastResultIndex
} = editor.storage.searchAndReplace;
if (
!docChanged &&
lastSearchTerm === searchTerm &&
lastCaseSensitive === caseSensitive &&
lastResultIndex === resultIndex
)
return oldState;
setLastSearchTerm(searchTerm);
setLastCaseSensitive(caseSensitive);
setLastResultIndex(resultIndex);
if (!searchTerm) {
editor.storage.searchAndReplace.results = [];
return DecorationSet.empty;
}
const { decorationsToReturn, results } = processSearches(
doc,
getRegex(searchTerm, disableRegex, caseSensitive),
searchResultClass,
resultIndex
);
editor.storage.searchAndReplace.results = results;
return decorationsToReturn;
}
},
props: {
decorations(state) {
return this.getState(state);
}
}
})
];
}
});
export default SearchAndReplace;
@@ -0,0 +1,34 @@
import { Audio } from './AudioExtension';
import type { Component } from 'svelte';
import type { NodeViewProps } from '@tiptap/core';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
export const AudioExtended = (content: Component<NodeViewProps>) =>
Audio.extend({
addAttributes() {
return {
src: {
default: null
},
alt: {
default: null
},
title: {
default: null
},
width: {
default: '100%'
},
height: {
default: null
},
align: {
default: 'left'
}
};
},
addNodeView: () => {
return SvelteNodeViewRenderer(content);
}
});
@@ -0,0 +1,147 @@
import { Node, nodeInputRule } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
export interface AudioOptions {
HTMLAttributes: Record<string, unknown>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
audio: {
/**
* Set a audio node
*/
setAudio: (src: string) => ReturnType;
/**
* Toggle a audio
*/
toggleAudio: (src: string) => ReturnType;
/**
* Remove a audio
*/
removeAudio: () => ReturnType;
};
}
}
const AUDIO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
export const Audio = Node.create<AudioOptions>({
name: 'audio',
group: 'block',
draggable: true,
isolating: true,
atom: true,
addOptions() {
return {
HTMLAttributes: {}
};
},
addAttributes() {
return {
src: {
default: null,
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
renderHTML: (attrs) => ({ src: attrs.src })
}
};
},
parseHTML() {
return [
{
tag: 'audio',
getAttrs: (el) => ({ src: (el as HTMLAudioElement).getAttribute('src') })
}
];
},
renderHTML({ HTMLAttributes }) {
return [
'audio',
{ controls: 'true', style: 'width: 100%;', ...HTMLAttributes },
['source', HTMLAttributes]
];
},
addCommands() {
return {
setAudio:
(src: string) =>
({ commands }) =>
commands.insertContent(
`<audio controls autoplay="false" style="width: 100%;" src="${src}"></audio>`
),
toggleAudio:
() =>
({ commands }) =>
commands.toggleNode(this.name, 'paragraph'),
removeAudio:
() =>
({ commands }) =>
commands.deleteNode(this.name)
};
},
addInputRules() {
return [
nodeInputRule({
find: AUDIO_INPUT_REGEX,
type: this.type,
getAttributes: (match) => {
const [, , src] = match;
return { src };
}
})
];
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('audioDropPlugin'),
props: {
handleDOMEvents: {
drop(view, event) {
const {
state: { schema, tr },
dispatch
} = view;
const hasFiles =
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length;
if (!hasFiles) return false;
const audios = Array.from(event.dataTransfer.files).filter((file) =>
/audio/i.test(file.type)
);
if (audios.length === 0) return false;
event.preventDefault();
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
audios.forEach((audio) => {
const reader = new FileReader();
reader.onload = (readerEvent) => {
const node = schema.nodes.audio.create({ src: readerEvent.target?.result });
if (coordinates && typeof coordinates.pos === 'number') {
const transaction = tr.insert(coordinates?.pos, node);
dispatch(transaction);
}
};
reader.readAsDataURL(audio);
});
return true;
}
}
}
})
];
}
});
@@ -0,0 +1,64 @@
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
export interface AudioPlaceholderOptions {
HTMLAttributes: Record<string, object>;
onDrop: (files: File[], editor: Editor) => void;
onDropRejected?: (files: File[], editor: Editor) => void;
onEmbed: (url: string, editor: Editor) => void;
allowedMimeTypes?: Record<string, string[]>;
maxFiles?: number;
maxSize?: number;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
audioPlaceholder: {
/**
* Inserts an audio placeholder
*/
insertAudioPlaceholder: () => ReturnType;
};
}
}
export const AudioPlaceholder = (
component: Component<NodeViewProps>
): Node<AudioPlaceholderOptions> =>
Node.create<AudioPlaceholderOptions>({
name: 'audio-placeholder',
addOptions() {
return {
HTMLAttributes: {},
onDrop: () => {},
onDropRejected: () => {},
onEmbed: () => {}
};
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)];
},
group: 'block',
draggable: true,
atom: true,
content: 'inline*',
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(component);
},
addCommands() {
return {
insertAudioPlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'audio-placeholder'
});
}
};
}
});
@@ -0,0 +1,28 @@
import { Slice } from '@tiptap/pm/model';
import { EditorView } from '@tiptap/pm/view';
import * as pmView from '@tiptap/pm/view';
function getPmView() {
try {
return pmView;
} catch (error) {
console.error(error);
return null;
}
}
export function serializeForClipboard(view: EditorView, slice: Slice) {
// Newer Tiptap/ProseMirror
if (view && typeof view.serializeForClipboard === 'function') {
return view.serializeForClipboard(slice);
}
// Older version fallback
const proseMirrorView = getPmView();
if (proseMirrorView && typeof proseMirrorView?.__serializeForClipboard === 'function') {
return proseMirrorView.__serializeForClipboard(view, slice);
}
throw new Error('No supported clipboard serialization method found.');
}
@@ -0,0 +1,374 @@
import { Fragment, Node, Slice } from '@tiptap/pm/model';
import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
import { EditorView } from '@tiptap/pm/view';
import { Extension } from '@tiptap/core';
import { serializeForClipboard } from './ClipboardSerializer';
export interface GlobalDragHandleOptions {
/**
* The width of the drag handle
*/
dragHandleWidth: number;
/**
* The treshold for scrolling
*/
scrollTreshold: number;
/*
* The css selector to query for the drag handle. (eg: '.custom-handle').
* If handle element is found, that element will be used as drag handle. If not, a default handle will be created
*/
dragHandleSelector?: string;
/**
* Tags to be excluded for drag handle
*/
excludedTags: string[];
/**
* Custom nodes to be included for drag handle
*/
customNodes: string[];
/**
* onNodeChange callback for drag handle
* @param data
* @returns
*/
onMouseMove?: (data: { node: Node; pos: number }) => void;
}
function absoluteRect(node: Element) {
const data = node.getBoundingClientRect();
const modal = node.closest('[role="dialog"]');
if (modal && window.getComputedStyle(modal).transform !== 'none') {
const modalRect = modal.getBoundingClientRect();
return {
top: data.top - modalRect.top,
left: data.left - modalRect.left,
width: data.width
};
}
return {
top: data.top,
left: data.left,
width: data.width
};
}
function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHandleOptions) {
const selectors = [
'li',
'p:not(:first-child)',
'pre',
'blockquote',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
...options.customNodes.map((node) => `[data-type=${node}]`)
].join(', ');
return document
.elementsFromPoint(coords.x, coords.y)
.find((elem: Element) => elem.parentElement?.matches?.('.ProseMirror') || elem.matches(selectors));
}
function nodePosAtDOM(node: Element, view: EditorView, options: GlobalDragHandleOptions) {
const boundingRect = node.getBoundingClientRect();
return view.posAtCoords({
left: boundingRect.left + 50 + options.dragHandleWidth,
top: boundingRect.top + 1
})?.inside;
}
function calcNodePos(pos: number, view: EditorView) {
const $pos = view.state.doc.resolve(pos);
if ($pos.depth > 1) return $pos.before($pos.depth);
return pos;
}
export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey: string }) {
let listType = '';
function handleDragStart(event: DragEvent, view: EditorView) {
view.focus();
if (!event.dataTransfer) return;
const node = nodeDOMAtCoords(
{
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY
},
options
);
if (!(node instanceof Element)) return;
let draggedNodePos = nodePosAtDOM(node, view, options);
if (draggedNodePos == null || draggedNodePos < 0) return;
draggedNodePos = calcNodePos(draggedNodePos, view);
const { from, to } = view.state.selection;
const diff = from - to;
const fromSelectionPos = calcNodePos(from, view);
let differentNodeSelected = false;
const nodePos = view.state.doc.resolve(fromSelectionPos);
// Check if nodePos points to the top level node
if (nodePos.node().type.name === 'doc') differentNodeSelected = true;
else {
const nodeSelection = NodeSelection.create(view.state.doc, nodePos.before());
// Check if the node where the drag event started is part of the current selection
differentNodeSelected = !(
draggedNodePos + 1 >= nodeSelection.$from.pos && draggedNodePos <= nodeSelection.$to.pos
);
}
let selection = view.state.selection;
if (!differentNodeSelected && diff !== 0 && !(view.state.selection instanceof NodeSelection)) {
const endSelection = NodeSelection.create(view.state.doc, to - 1);
selection = TextSelection.create(view.state.doc, draggedNodePos, endSelection.$to.pos);
} else {
selection = NodeSelection.create(view.state.doc, draggedNodePos);
// if inline node is selected, e.g mention -> go to the parent node to select the whole node
// if table row is selected, go to the parent node to select the whole node
if (
(selection as NodeSelection).node.type.isInline ||
(selection as NodeSelection).node.type.name === 'tableRow'
) {
const $pos = view.state.doc.resolve(selection.from);
selection = NodeSelection.create(view.state.doc, $pos.before());
}
}
view.dispatch(view.state.tr.setSelection(selection));
// If the selected node is a list item, we need to save the type of the wrapping list e.g. OL or UL
if (view.state.selection instanceof NodeSelection && view.state.selection.node.type.name === 'listItem') {
listType = node.parentElement!.tagName;
}
const slice = view.state.selection.content();
const { dom, text } = serializeForClipboard(view, slice);
event.dataTransfer.clearData();
event.dataTransfer.setData('text/html', dom.innerHTML);
event.dataTransfer.setData('text/plain', text);
event.dataTransfer.effectAllowed = 'copyMove';
event.dataTransfer.setDragImage(node, 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
let dragHandleElement: HTMLElement | null = null;
function hideDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.add('hide');
}
}
function showDragHandle() {
if (dragHandleElement) {
dragHandleElement.classList.remove('hide');
}
}
function hideHandleOnEditorOut(event: MouseEvent) {
if (event.target instanceof Element) {
// Check if the relatedTarget class is still inside the editor
const relatedTarget = event.relatedTarget as HTMLElement;
const isInsideEditor =
relatedTarget?.classList.contains('tiptap') || relatedTarget?.classList.contains('drag-handle');
if (isInsideEditor) return;
}
hideDragHandle();
}
return new Plugin({
key: new PluginKey(options.pluginKey),
view: (view) => {
const handleBySelector = options.dragHandleSelector
? document.querySelector<HTMLElement>(options.dragHandleSelector)
: null;
dragHandleElement = handleBySelector ?? document.createElement('div');
dragHandleElement.draggable = true;
dragHandleElement.dataset.dragHandle = '';
dragHandleElement.classList.add('drag-handle');
function onDragHandleDragStart(e: DragEvent) {
handleDragStart(e, view);
}
dragHandleElement.addEventListener('dragstart', onDragHandleDragStart);
function onDragHandleDrag(e: DragEvent) {
hideDragHandle();
const scrollY = window.scrollY;
if (e.clientY < options.scrollTreshold) {
window.scrollTo({ top: scrollY - 30, behavior: 'smooth' });
} else if (window.innerHeight - e.clientY < options.scrollTreshold) {
window.scrollTo({ top: scrollY + 30, behavior: 'smooth' });
}
}
dragHandleElement.addEventListener('drag', onDragHandleDrag);
hideDragHandle();
if (!handleBySelector) {
view?.dom?.parentElement?.appendChild(dragHandleElement);
}
view?.dom?.parentElement?.addEventListener('mouseout', hideHandleOnEditorOut);
return {
destroy: () => {
if (!handleBySelector) {
dragHandleElement?.remove?.();
}
dragHandleElement?.removeEventListener('drag', onDragHandleDrag);
dragHandleElement?.removeEventListener('dragstart', onDragHandleDragStart);
dragHandleElement = null;
view?.dom?.parentElement?.removeEventListener('mouseout', hideHandleOnEditorOut);
}
};
},
props: {
handleDOMEvents: {
mousemove: (view, event) => {
if (!view.editable) {
return;
}
const node = nodeDOMAtCoords(
{
x: event.clientX + 50 + options.dragHandleWidth,
y: event.clientY
},
options
);
const notDragging = node?.closest('.not-draggable');
const excludedTagList = options.excludedTags.concat(['ol', 'ul']).join(', ');
if (!(node instanceof Element) || node.matches(excludedTagList) || notDragging) {
hideDragHandle();
return;
}
const nodePos = nodePosAtDOM(node, view, options);
if (nodePos !== undefined) {
const currentNode = view.state.doc.nodeAt(nodePos);
if (currentNode !== null) {
options.onMouseMove?.({ node: currentNode, pos: nodePos });
}
}
const compStyle = window.getComputedStyle(node);
const parsedLineHeight = parseInt(compStyle.lineHeight, 10);
const lineHeight = isNaN(parsedLineHeight) ? parseInt(compStyle.fontSize) * 1.2 : parsedLineHeight;
const paddingTop = parseInt(compStyle.paddingTop, 10);
const rect = absoluteRect(node);
rect.top += (lineHeight - 24) / 2;
rect.top += paddingTop;
// Li markers
if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
rect.left -= options.dragHandleWidth;
}
rect.width = options.dragHandleWidth;
if (!dragHandleElement) return;
dragHandleElement.style.left = `${rect.left - rect.width}px`;
dragHandleElement.style.top = `${rect.top}px`;
showDragHandle();
},
keydown: () => {
hideDragHandle();
},
mousewheel: () => {
hideDragHandle();
},
// dragging class is used for CSS
dragstart: (view) => {
view.dom.classList.add('dragging');
},
drop: (view, event) => {
view.dom.classList.remove('dragging');
hideDragHandle();
let droppedNode: Node | null = null;
const dropPos = view.posAtCoords({
left: event.clientX,
top: event.clientY
});
if (!dropPos) return;
if (view.state.selection instanceof NodeSelection) {
droppedNode = view.state.selection.node;
}
if (!droppedNode) return;
const resolvedPos = view.state.doc.resolve(dropPos.pos);
const isDroppedInsideList = resolvedPos.parent.type.name === 'listItem';
// If the selected node is a list item and is not dropped inside a list, we need to wrap it inside <ol> tag otherwise ol list items will be transformed into ul list item when dropped
if (
view.state.selection instanceof NodeSelection &&
view.state.selection.node.type.name === 'listItem' &&
!isDroppedInsideList &&
listType == 'OL'
) {
const newList = view.state.schema.nodes.orderedList?.createAndFill(null, droppedNode);
const slice = new Slice(Fragment.from(newList), 0, 0);
view.dragging = { slice, move: event.ctrlKey };
}
},
dragend: (view) => {
view.dom.classList.remove('dragging');
}
}
}
});
}
const GlobalDragHandle = Extension.create({
name: 'globalDragHandle',
addOptions() {
return {
dragHandleWidth: 20,
scrollTreshold: 100,
excludedTags: [],
customNodes: []
};
},
addProseMirrorPlugins() {
return [
DragHandlePlugin({
pluginKey: 'globalDragHandle',
dragHandleWidth: this.options.dragHandleWidth,
scrollTreshold: this.options.scrollTreshold,
dragHandleSelector: this.options.dragHandleSelector,
excludedTags: this.options.excludedTags,
customNodes: this.options.customNodes,
onMouseMove: this.options.onMouseMove
})
];
}
});
export default GlobalDragHandle;
@@ -0,0 +1,85 @@
import { Node } from '@tiptap/core';
export interface IframeOptions {
allowFullscreen: boolean;
HTMLAttributes: {
[key: string]: unknown;
};
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
iframe: {
/**
* Add an iframe with src
*/
setIframe: (options: { src: string }) => ReturnType;
removeIframe: () => ReturnType;
};
}
}
export default Node.create<IframeOptions>({
name: 'iframe',
group: 'block',
atom: true,
addOptions() {
return {
allowFullscreen: true,
HTMLAttributes: {
class: 'iframe-wrapper'
}
};
},
addAttributes() {
return {
src: {
default: null
},
frameborder: {
default: 0
},
allowfullscreen: {
default: this.options.allowFullscreen,
parseHTML: () => this.options.allowFullscreen
}
};
},
parseHTML() {
return [
{
tag: 'iframe'
}
];
},
renderHTML({ HTMLAttributes }) {
return ['div', this.options.HTMLAttributes, ['iframe', HTMLAttributes]];
},
addCommands() {
return {
setIframe:
(options: { src: string }) =>
({ tr, dispatch }) => {
const { selection } = tr;
const node = this.type.create(options);
if (dispatch) {
tr.replaceRangeWith(selection.from, selection.to, node);
}
return true;
},
removeIframe:
() =>
({ commands }) =>
commands.deleteNode(this.name)
};
}
});
@@ -0,0 +1,34 @@
import type { Component } from 'svelte';
import IFrame from './IFrame';
import type { NodeViewProps } from '@tiptap/core';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
export const IFrameExtended = (content: Component<NodeViewProps>) =>
IFrame.extend({
addAttributes() {
return {
src: {
default: null
},
alt: {
default: null
},
title: {
default: null
},
width: {
default: '100%'
},
height: {
default: null
},
align: {
default: 'left'
}
};
},
addNodeView: () => {
return SvelteNodeViewRenderer(content);
}
});
@@ -0,0 +1,56 @@
import { Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
export interface IFramePlaceholderOptions {
HTMLAttributes: Record<string, object>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
iframePlaceholder: {
/**
* Inserts a IFrame placeholder
*/
insertIFramePlaceholder: () => ReturnType;
};
}
}
export const IFramePlaceholder = (content: Component<NodeViewProps>) =>
Node.create<IFramePlaceholderOptions>({
name: 'iframe-placeholder',
addOptions() {
return {
HTMLAttributes: {},
onDrop: () => {},
onDropRejected: () => {},
onEmbed: () => {}
};
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)];
},
group: 'block',
draggable: true,
atom: true,
content: 'inline*',
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(content);
},
addCommands() {
return {
insertIFramePlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'iframe-placeholder'
});
}
};
}
});
@@ -0,0 +1,36 @@
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import Image, { type ImageOptions } from '@tiptap/extension-image';
import type { Component } from 'svelte';
import type { NodeViewProps, Node } from '@tiptap/core';
export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOptions, unknown> => {
return Image.extend({
addAttributes() {
return {
src: {
default: null
},
alt: {
default: null
},
title: {
default: null
},
width: {
default: '100%'
},
height: {
default: null
},
align: {
default: 'left'
}
};
},
addNodeView: () => {
return SvelteNodeViewRenderer(component);
}
}).configure({
allowBase64: true
});
};
@@ -0,0 +1,64 @@
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
export interface ImagePlaceholderOptions {
HTMLAttributes: Record<string, object>;
onDrop: (files: File[], editor: Editor) => void;
onDropRejected?: (files: File[], editor: Editor) => void;
onEmbed: (url: string, editor: Editor) => void;
allowedMimeTypes?: Record<string, string[]>;
maxFiles?: number;
maxSize?: number;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
imagePlaceholder: {
/**
* Inserts an image placeholder
*/
insertImagePlaceholder: () => ReturnType;
};
}
}
export const ImagePlaceholder = (
component: Component<NodeViewProps>
): Node<ImagePlaceholderOptions> =>
Node.create<ImagePlaceholderOptions>({
name: 'image-placeholder',
addOptions() {
return {
HTMLAttributes: {},
onDrop: () => {},
onDropRejected: () => {},
onEmbed: () => {}
};
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)];
},
group: 'block',
draggable: true,
atom: true,
content: 'inline*',
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(component);
},
addCommands() {
return {
insertImagePlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'image-placeholder'
});
}
};
}
});
@@ -0,0 +1,57 @@
import type { Editor } from '@tiptap/core';
import type { EdraToolBarCommands } from '../../commands/types';
import Minus from '@lucide/svelte/icons/minus';
import Quote from '@lucide/svelte/icons/quote';
import SquareCode from '@lucide/svelte/icons/square-code';
import commands from '../../commands/toolbar-commands';
export interface Group {
name: string;
title: string;
actions: EdraToolBarCommands[];
}
export const GROUPS: Group[] = [
{
name: 'format',
title: 'Format',
actions: [
...commands.headings,
{
icon: Quote,
name: 'blockquote',
tooltip: 'Blockquote',
onClick: (editor: Editor) => {
editor.chain().focus().setBlockquote().run();
}
},
{
icon: SquareCode,
name: 'codeBlock',
tooltip: 'Code Block',
onClick: (editor: Editor) => {
editor.chain().focus().setCodeBlock().run();
}
},
...commands.lists
]
},
{
name: 'insert',
title: 'Insert',
actions: [
...commands.media,
...commands.table,
{
icon: Minus,
name: 'horizontalRule',
tooltip: 'Horizontal Rule',
onClick: (editor: Editor) => {
editor.chain().focus().setHorizontalRule().run();
}
}
]
}
];
export default GROUPS;
@@ -0,0 +1,255 @@
import { Editor, Extension } from '@tiptap/core';
import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion';
import { PluginKey } from '@tiptap/pm/state';
import { computePosition, flip, offset, autoUpdate, type Placement } from '@floating-ui/dom';
import { GROUPS } from './groups';
import SvelteRenderer from '../../svelte-renderer';
import type { Component } from 'svelte';
const extensionName = 'slashCommand';
interface PopupState {
element: HTMLElement | null;
cleanup: (() => void) | null;
isVisible: boolean;
}
const popup: PopupState = {
element: null,
cleanup: null,
isVisible: false
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default (menuList: Component<any, any, ''>): Extension =>
Extension.create({
name: extensionName,
priority: 200,
onCreate() {
// Create popup container
popup.element = document.createElement('div');
popup.element.style.position = 'fixed';
popup.element.style.zIndex = '9999';
popup.element.style.maxWidth = '16rem';
popup.element.style.visibility = 'hidden';
popup.element.style.pointerEvents = 'none';
popup.element.className = 'slash-command-popup';
document.body.appendChild(popup.element);
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
char: '/',
allowSpaces: true,
pluginKey: new PluginKey(extensionName),
allow: ({ state, range }) => {
const $from = state.doc.resolve(range.from);
const afterContent = $from.parent.textContent?.substring($from.parent.textContent?.indexOf('/'));
const isValidAfterContent = !afterContent?.endsWith(' ');
return isValidAfterContent;
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
command: ({ editor, props }: { editor: Editor; props: any }) => {
const { view, state } = editor;
const { $head, $from } = view.state.selection;
try {
const end = $from.pos;
const from = $head?.nodeBefore
? end - ($head.nodeBefore.text?.substring($head.nodeBefore.text?.indexOf('/')).length ?? 0)
: $from.start();
const tr = state.tr.deleteRange(from, end);
view.dispatch(tr);
} catch (error) {
console.error(error);
}
props.onClick(editor);
view.focus();
},
items: ({ query }: { query: string }) => {
const withFilteredCommands = GROUPS.map((group) => ({
...group,
commands: group.actions.filter((item) => {
const labelNormalized = item.tooltip!.toLowerCase().trim();
const queryNormalized = query.toLowerCase().trim();
return labelNormalized.includes(queryNormalized);
})
}));
const withoutEmptyGroups = withFilteredCommands.filter((group) => {
if (group.commands.length > 0) {
return true;
}
return false;
});
const withEnabledSettings = withoutEmptyGroups.map((group) => ({
...group,
commands: group.commands.map((command) => ({
...command,
isEnabled: true
}))
}));
return withEnabledSettings;
},
render: () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let component: any;
let scrollHandler: (() => void) | null = null;
return {
onStart: (props: SuggestionProps) => {
component = new SvelteRenderer(menuList, {
props,
editor: props.editor
});
const { view } = props.editor;
if (popup.element) {
popup.element.appendChild(component.element);
popup.element.style.visibility = 'visible';
popup.element.style.pointerEvents = 'auto';
popup.isVisible = true;
const updatePosition = () => {
if (!popup.element || !props.clientRect) return;
const rect = props.clientRect();
if (!rect) return;
const referenceElement = {
getBoundingClientRect: () => rect
};
computePosition(referenceElement, popup.element, {
placement: 'bottom-start' as Placement,
middleware: [
offset({ mainAxis: 8, crossAxis: 16 }),
flip({ fallbackPlacements: ['top-start', 'bottom-start'] })
]
}).then(({ x, y }) => {
if (popup.element) {
popup.element.style.left = `${x}px`;
popup.element.style.top = `${y}px`;
}
});
};
updatePosition();
// Set up auto-update for scroll events
if (props.clientRect) {
const referenceElement = {
getBoundingClientRect: () => props.clientRect?.() || new DOMRect()
};
popup.cleanup = autoUpdate(referenceElement, popup.element, updatePosition);
}
scrollHandler = updatePosition;
view.dom.parentElement?.addEventListener('scroll', scrollHandler);
}
},
onUpdate(props: SuggestionProps) {
component.updateProps(props);
if (popup.element && popup.isVisible && props.clientRect) {
const rect = props.clientRect();
if (rect) {
const referenceElement = {
getBoundingClientRect: () => rect
};
computePosition(referenceElement, popup.element, {
placement: 'bottom-start' as Placement,
middleware: [
offset({ mainAxis: 8, crossAxis: 16 }),
flip({ fallbackPlacements: ['top-start', 'bottom-start'] })
]
}).then(({ x, y }) => {
if (popup.element) {
popup.element.style.left = `${x}px`;
popup.element.style.top = `${y}px`;
}
});
props.editor.storage[extensionName].rect = rect;
}
}
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === 'Escape') {
if (popup.element) {
popup.element.style.visibility = 'hidden';
popup.element.style.pointerEvents = 'none';
popup.isVisible = false;
}
return true;
}
if (!popup.isVisible && popup.element) {
popup.element.style.visibility = 'visible';
popup.element.style.pointerEvents = 'auto';
popup.isVisible = true;
}
if (props.event.key === 'Enter') return true;
// return component.ref?.onKeyDown(props);
return false;
},
onExit(props) {
if (popup.element) {
popup.element.style.visibility = 'hidden';
popup.element.style.pointerEvents = 'none';
popup.element.innerHTML = '';
popup.isVisible = false;
}
if (popup.cleanup) {
popup.cleanup();
popup.cleanup = null;
}
if (scrollHandler) {
const { view } = props.editor;
view.dom.parentElement?.removeEventListener('scroll', scrollHandler);
scrollHandler = null;
}
component.destroy();
}
};
}
})
];
},
addStorage() {
return {
rect: {
width: 0,
height: 0,
left: 0,
top: 0,
right: 0,
bottom: 0
}
};
}
});
@@ -0,0 +1,4 @@
export { Table } from './table';
export { TableCell } from './table-cell';
export { TableRow } from './table-row';
export { TableHeader } from './table-header';
@@ -0,0 +1,124 @@
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { Node, mergeAttributes } from '@tiptap/core';
import { getCellsInColumn, isRowSelected, selectRow } from './utils';
import { Plugin } from '@tiptap/pm/state';
export interface TableCellOptions {
HTMLAttributes: Record<string, unknown>;
}
export const TableCell = Node.create<TableCellOptions>({
name: 'tableCell',
content: 'block+',
tableRole: 'cell',
isolating: true,
addOptions() {
return {
HTMLAttributes: {}
};
},
parseHTML() {
return [{ tag: 'td' }];
},
renderHTML({ HTMLAttributes }) {
return ['td', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes), 0];
},
addAttributes() {
return {
colspan: {
default: 1,
parseHTML: (element) => {
const colspan = element.getAttribute('colspan');
const value = colspan ? parseInt(colspan, 10) : 1;
return value;
}
},
rowspan: {
default: 1,
parseHTML: (element) => {
const rowspan = element.getAttribute('rowspan');
const value = rowspan ? parseInt(rowspan, 10) : 1;
return value;
}
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute('colwidth');
const value = colwidth ? [parseInt(colwidth, 10)] : null;
return value;
}
},
style: {
default: null
}
};
},
addProseMirrorPlugins() {
const { isEditable } = this.editor;
return [
new Plugin({
props: {
decorations: (state) => {
if (!isEditable) {
return DecorationSet.empty;
}
const { doc, selection } = state;
const decorations: Decoration[] = [];
const cells = getCellsInColumn(0)(selection);
if (cells) {
cells.forEach(({ pos }: { pos: number }, index: number) => {
decorations.push(
Decoration.widget(pos + 1, () => {
const rowSelected = isRowSelected(index)(selection);
let className = 'grip-row';
if (rowSelected) {
className += ' selected';
}
if (index === 0) {
className += ' first';
}
if (index === cells.length - 1) {
className += ' last';
}
const grip = document.createElement('a');
grip.className = className;
grip.addEventListener('mousedown', (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.editor.view.dispatch(selectRow(index)(this.editor.state.tr));
});
return grip;
})
);
});
}
return DecorationSet.create(doc, decorations);
}
}
})
];
}
});
@@ -0,0 +1,89 @@
import { Decoration, DecorationSet } from '@tiptap/pm/view';
import { getCellsInRow, isColumnSelected, selectColumn } from './utils';
import { Plugin } from '@tiptap/pm/state';
import { TableHeader as TiptapTableHeader } from '@tiptap/extension-table';
export const TableHeader = TiptapTableHeader.extend({
addAttributes() {
return {
colspan: {
default: 1
},
rowspan: {
default: 1
},
colwidth: {
default: null,
parseHTML: (element) => {
const colwidth = element.getAttribute('colwidth');
const value = colwidth ? colwidth.split(',').map((item) => parseInt(item, 10)) : null;
return value;
}
},
style: {
default: null
}
};
},
addProseMirrorPlugins() {
const { isEditable } = this.editor;
return [
new Plugin({
props: {
decorations: (state) => {
if (!isEditable) {
return DecorationSet.empty;
}
const { doc, selection } = state;
const decorations: Decoration[] = [];
const cells = getCellsInRow(0)(selection);
if (cells) {
cells.forEach(({ pos }: { pos: number }, index: number) => {
decorations.push(
Decoration.widget(pos + 1, () => {
const colSelected = isColumnSelected(index)(selection);
let className = 'grip-column';
if (colSelected) {
className += ' selected';
}
if (index === 0) {
className += ' first';
}
if (index === cells.length - 1) {
className += ' last';
}
const grip = document.createElement('a');
grip.className = className;
grip.addEventListener('mousedown', (event) => {
event.preventDefault();
event.stopImmediatePropagation();
this.editor.view.dispatch(selectColumn(index)(this.editor.state.tr));
});
return grip;
})
);
});
}
return DecorationSet.create(doc, decorations);
}
}
})
];
}
});
export default TableHeader;
@@ -0,0 +1,8 @@
import { TableRow as TiptapTableRow } from '@tiptap/extension-table';
export const TableRow = TiptapTableRow.extend({
allowGapCursor: false,
content: '(tableCell | tableHeader)*'
});
export default TableRow;
@@ -0,0 +1,9 @@
import { Table as TiptapTable } from '@tiptap/extension-table';
export const Table = TiptapTable.configure({
resizable: true,
lastColumnResizable: true,
allowTableNodeSelection: true
});
export default Table;
@@ -0,0 +1,313 @@
import { Editor, findParentNode } from '@tiptap/core';
import { EditorState, Selection, Transaction } from '@tiptap/pm/state';
import { CellSelection, type Rect, TableMap } from '@tiptap/pm/tables';
import { Node, ResolvedPos } from '@tiptap/pm/model';
import type { EditorView } from '@tiptap/pm/view';
import Table from './table';
export const isRectSelected = (rect: Rect) => (selection: CellSelection) => {
const map = TableMap.get(selection.$anchorCell.node(-1));
const start = selection.$anchorCell.start(-1);
const cells = map.cellsInRect(rect);
const selectedCells = map.cellsInRect(
map.rectBetween(selection.$anchorCell.pos - start, selection.$headCell.pos - start)
);
for (let i = 0, count = cells.length; i < count; i += 1) {
if (selectedCells.indexOf(cells[i]) === -1) {
return false;
}
}
return true;
};
export const findTable = (selection: Selection) =>
findParentNode((node) => node.type.spec.tableRole && node.type.spec.tableRole === 'table')(selection);
export const isCellSelection = (selection: Selection): selection is CellSelection => selection instanceof CellSelection;
export const isColumnSelected = (columnIndex: number) => (selection: Selection) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: columnIndex,
right: columnIndex + 1,
top: 0,
bottom: map.height
})(selection);
}
return false;
};
export const isRowSelected = (rowIndex: number) => (selection: Selection) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: 0,
right: map.width,
top: rowIndex,
bottom: rowIndex + 1
})(selection);
}
return false;
};
export const isTableSelected = (selection: Selection) => {
if (isCellSelection(selection)) {
const map = TableMap.get(selection.$anchorCell.node(-1));
return isRectSelected({
left: 0,
right: map.width,
top: 0,
bottom: map.height
})(selection);
}
return false;
};
export const getCellsInColumn = (columnIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(columnIndex) ? columnIndex : Array.from([columnIndex]);
return indexes.reduce(
(acc, index) => {
if (index >= 0 && index <= map.width - 1) {
const cells = map.cellsInRect({
left: index,
right: index + 1,
top: 0,
bottom: map.height
});
return acc.concat(
cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
})
);
}
return acc;
},
[] as { pos: number; start: number; node: Node | null | undefined }[]
);
}
return null;
};
export const getCellsInRow = (rowIndex: number | number[]) => (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const indexes = Array.isArray(rowIndex) ? rowIndex : Array.from([rowIndex]);
return indexes.reduce(
(acc, index) => {
if (index >= 0 && index <= map.height - 1) {
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: index,
bottom: index + 1
});
return acc.concat(
cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
})
);
}
return acc;
},
[] as { pos: number; start: number; node: Node | null | undefined }[]
);
}
return null;
};
export const getCellsInTable = (selection: Selection) => {
const table = findTable(selection);
if (table) {
const map = TableMap.get(table.node);
const cells = map.cellsInRect({
left: 0,
right: map.width,
top: 0,
bottom: map.height
});
return cells.map((nodePos) => {
const node = table.node.nodeAt(nodePos);
const pos = nodePos + table.start;
return { pos, start: pos + 1, node };
});
}
return null;
};
export const findParentNodeClosestToPos = ($pos: ResolvedPos, predicate: (node: Node) => boolean) => {
for (let i = $pos.depth; i > 0; i -= 1) {
const node = $pos.node(i);
if (predicate(node)) {
return {
pos: i > 0 ? $pos.before(i) : 0,
start: $pos.start(i),
depth: i,
node
};
}
}
return null;
};
export const findCellClosestToPos = ($pos: ResolvedPos) => {
const predicate = (node: Node) => node.type.spec.tableRole && /cell/i.test(node.type.spec.tableRole);
return findParentNodeClosestToPos($pos, predicate);
};
const select = (type: 'row' | 'column') => (index: number) => (tr: Transaction) => {
const table = findTable(tr.selection);
const isRowSelection = type === 'row';
if (table) {
const map = TableMap.get(table.node);
// Check if the index is valid
if (index >= 0 && index < (isRowSelection ? map.height : map.width)) {
const left = isRowSelection ? 0 : index;
const top = isRowSelection ? index : 0;
const right = isRowSelection ? map.width : index + 1;
const bottom = isRowSelection ? index + 1 : map.height;
const cellsInFirstRow = map.cellsInRect({
left,
top,
right: isRowSelection ? right : left + 1,
bottom: isRowSelection ? top + 1 : bottom
});
const cellsInLastRow =
bottom - top === 1
? cellsInFirstRow
: map.cellsInRect({
left: isRowSelection ? left : right - 1,
top: isRowSelection ? bottom - 1 : top,
right,
bottom
});
const head = table.start + cellsInFirstRow[0];
const anchor = table.start + cellsInLastRow[cellsInLastRow.length - 1];
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
return tr.setSelection(new CellSelection($anchor, $head));
}
}
return tr;
};
export const selectColumn = select('column');
export const selectRow = select('row');
export const selectTable = (tr: Transaction) => {
const table = findTable(tr.selection);
if (table) {
const { map } = TableMap.get(table.node);
if (map && map.length) {
const head = table.start + map[0];
const anchor = table.start + map[map.length - 1];
const $head = tr.doc.resolve(head);
const $anchor = tr.doc.resolve(anchor);
return tr.setSelection(new CellSelection($anchor, $head));
}
}
return tr;
};
export const isColumnGripSelected = ({
editor,
view,
state,
from
}: {
editor: Editor;
view: EditorView;
state: EditorState;
from: number;
}) => {
const domAtPos = view.domAtPos(from).node as HTMLElement;
const nodeDOM = view.nodeDOM(from) as HTMLElement;
const node = nodeDOM || domAtPos;
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
return false;
}
let container = node;
while (container && !['TD', 'TH'].includes(container.tagName)) {
container = container.parentElement!;
}
const gripColumn = container && container.querySelector && container.querySelector('a.grip-column.selected');
return !!gripColumn;
};
export const isRowGripSelected = ({
editor,
view,
state,
from
}: {
editor: Editor;
view: EditorView;
state: EditorState;
from: number;
}) => {
const domAtPos = view.domAtPos(from).node as HTMLElement;
const nodeDOM = view.nodeDOM(from) as HTMLElement;
const node = nodeDOM || domAtPos;
if (!editor.isActive(Table.name) || !node || isTableSelected(state.selection)) {
return false;
}
let container = node;
while (container && !['TD', 'TH'].includes(container.tagName)) {
container = container.parentElement!;
}
const gripRow = container && container.querySelector && container.querySelector('a.grip-row.selected');
return !!gripRow;
};
@@ -0,0 +1,34 @@
import type { Component } from 'svelte';
import type { NodeViewProps } from '@tiptap/core';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
import { Video } from './VideoExtension';
export const VideoExtended = (content: Component<NodeViewProps>) =>
Video.extend({
addAttributes() {
return {
src: {
default: null
},
alt: {
default: null
},
title: {
default: null
},
width: {
default: '100%'
},
height: {
default: null
},
align: {
default: 'left'
}
};
},
addNodeView: () => {
return SvelteNodeViewRenderer(content);
}
});
@@ -0,0 +1,147 @@
import { Node, nodeInputRule } from '@tiptap/core';
import { Plugin, PluginKey } from '@tiptap/pm/state';
export interface VideoOptions {
HTMLAttributes: Record<string, unknown>;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
video: {
/**
* Set a video node
*/
setVideo: (src: string) => ReturnType;
/**
* Toggle a video
*/
toggleVideo: (src: string) => ReturnType;
/**
* Remove a video
*/
removeVideo: () => ReturnType;
};
}
}
const VIDEO_INPUT_REGEX = /!\[(.+|:?)]\((\S+)(?:(?:\s+)["'](\S+)["'])?\)/;
export const Video = Node.create<VideoOptions>({
name: 'video',
group: 'block',
content: 'inline*',
draggable: true,
isolating: true,
addOptions() {
return {
HTMLAttributes: {}
};
},
addAttributes() {
return {
src: {
default: null,
parseHTML: (el) => (el as HTMLSpanElement).getAttribute('src'),
renderHTML: (attrs) => ({ src: attrs.src })
}
};
},
parseHTML() {
return [
{
tag: 'video',
getAttrs: (el) => ({ src: (el as HTMLVideoElement).getAttribute('src') })
}
];
},
renderHTML({ HTMLAttributes }) {
return [
'video',
{ controls: 'true', style: 'width: fit-content;', ...HTMLAttributes },
['source', HTMLAttributes]
];
},
addCommands() {
return {
setVideo:
(src: string) =>
({ commands }) =>
commands.insertContent(
`<video controls="true" autoplay="false" style="width: fit-content" src="${src}" />`
),
toggleVideo:
() =>
({ commands }) =>
commands.toggleNode(this.name, 'paragraph'),
removeVideo:
() =>
({ commands }) =>
commands.deleteNode(this.name)
};
},
addInputRules() {
return [
nodeInputRule({
find: VIDEO_INPUT_REGEX,
type: this.type,
getAttributes: (match) => {
const [, , src] = match;
return { src };
}
})
];
},
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('videoDropPlugin'),
props: {
handleDOMEvents: {
drop(view, event) {
const {
state: { schema, tr },
dispatch
} = view;
const hasFiles =
event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length;
if (!hasFiles) return false;
const videos = Array.from(event.dataTransfer.files).filter((file) =>
/video/i.test(file.type)
);
if (videos.length === 0) return false;
event.preventDefault();
const coordinates = view.posAtCoords({ left: event.clientX, top: event.clientY });
videos.forEach((video) => {
const reader = new FileReader();
reader.onload = (readerEvent) => {
const node = schema.nodes.video.create({ src: readerEvent.target?.result });
if (coordinates && typeof coordinates.pos === 'number') {
const transaction = tr.insert(coordinates?.pos, node);
dispatch(transaction);
}
};
reader.readAsDataURL(video);
});
return true;
}
}
}
})
];
}
});
@@ -0,0 +1,62 @@
import { Editor, Node, mergeAttributes, type CommandProps, type NodeViewProps } from '@tiptap/core';
import type { Component } from 'svelte';
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
export interface VideoPlaceholderOptions {
HTMLAttributes: Record<string, object>;
onDrop: (files: File[], editor: Editor) => void;
onDropRejected?: (files: File[], editor: Editor) => void;
onEmbed: (url: string, editor: Editor) => void;
allowedMimeTypes?: Record<string, string[]>;
maxFiles?: number;
maxSize?: number;
}
declare module '@tiptap/core' {
interface Commands<ReturnType> {
videoPlaceholder: {
/**
* Inserts a video placeholder
*/
insertVideoPlaceholder: () => ReturnType;
};
}
}
export const VideoPlaceholder = (content: Component<NodeViewProps>) =>
Node.create<VideoPlaceholderOptions>({
name: 'video-placeholder',
addOptions() {
return {
HTMLAttributes: {},
onDrop: () => {},
onDropRejected: () => {},
onEmbed: () => {}
};
},
parseHTML() {
return [{ tag: `div[data-type="${this.name}"]` }];
},
renderHTML({ HTMLAttributes }) {
return ['div', mergeAttributes(HTMLAttributes)];
},
group: 'block',
draggable: true,
atom: true,
content: 'inline*',
isolating: true,
addNodeView() {
return SvelteNodeViewRenderer(content);
},
addCommands() {
return {
insertVideoPlaceholder: () => (props: CommandProps) => {
return props.commands.insertContent({
type: 'video-placeholder'
});
}
};
}
});
+1
View File
@@ -0,0 +1 @@
export { default as Editor } from './editor.svelte';
+176
View File
@@ -0,0 +1,176 @@
/* One Dark and Light Theme for Highlight.js using Pure CSS */
/* Light Theme (Default) */
.tiptap pre code {
color: #383a42;
}
/* Comment */
.hljs-comment,
.hljs-quote {
font-style: italic;
color: #a0a1a7;
}
/* Red */
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
color: #e45649;
}
/* Orange */
.hljs-number,
.hljs-built_in,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
color: #986801;
}
/* Yellow */
.hljs-attribute {
color: #c18401;
}
/* Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: #50a14f;
}
/* Blue */
.hljs-title,
.hljs-section {
color: #4078f2;
}
/* Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #a626a4;
}
/* Cyan */
.hljs-emphasis {
font-style: italic;
color: #0184bc;
}
.hljs-strong {
font-weight: bold;
}
/* Base styles */
.hljs-doctag,
.hljs-formula {
color: #a626a4;
}
.hljs-attr,
.hljs-subst {
color: #383a42;
}
/* Line highlights */
.hljs-addition {
background-color: #e6ffed;
}
.hljs-deletion {
background-color: #ffeef0;
}
/* Dark Theme (All dark styles consolidated in one media query) */
html.dark {
.tiptap pre code {
color: #abb2bf;
}
/* Comment */
.hljs-comment,
.hljs-quote {
color: #5c6370;
}
/* Red */
.hljs-variable,
.hljs-template-variable,
.hljs-tag,
.hljs-name,
.hljs-selector-id,
.hljs-selector-class,
.hljs-regexp,
.hljs-deletion {
color: #e06c75;
}
/* Orange */
.hljs-number,
.hljs-built_in,
.hljs-literal,
.hljs-type,
.hljs-params,
.hljs-meta,
.hljs-link {
color: #d19a66;
}
/* Yellow */
.hljs-attribute {
color: #e5c07b;
}
/* Green */
.hljs-string,
.hljs-symbol,
.hljs-bullet,
.hljs-addition {
color: #98c379;
}
/* Blue */
.hljs-title,
.hljs-section {
color: #61afef;
}
/* Purple */
.hljs-keyword,
.hljs-selector-tag {
color: #c678dd;
}
/* Cyan */
.hljs-emphasis {
color: #56b6c2;
}
/* Base styles */
.hljs-doctag,
.hljs-formula {
color: #c678dd;
}
.hljs-attr,
.hljs-subst {
color: #abb2bf;
}
/* Line highlights */
.hljs-addition {
background-color: #283428;
}
.hljs-deletion {
background-color: #342828;
}
}
@@ -0,0 +1,75 @@
import { flushSync, mount, unmount } from 'svelte';
import type { Editor, NodeViewProps } from '@tiptap/core';
interface RendererOptions<P extends Record<string, unknown>> {
editor: Editor;
props: P;
}
type App = ReturnType<typeof mount>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
class SvelteRenderer<R = unknown, P extends Record<string, any> = object> {
id: string;
component: App;
editor: Editor;
props: P;
element: HTMLElement;
ref: R | null = null;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mnt: Record<any, any> | null = null;
constructor(component: App, { props, editor }: RendererOptions<P>) {
this.id = Math.floor(Math.random() * 0xffffffff).toString();
this.component = component;
this.props = props;
this.editor = editor;
this.element = document.createElement('div');
this.element.classList.add('svelte-renderer');
if (this.editor.isInitialized) {
// On first render, we need to flush the render synchronously
// Renders afterwards can be async, but this fixes a cursor positioning issue
flushSync(() => {
this.render();
});
} else {
this.render();
}
}
render(): void {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
this.mnt = mount(this.component as any, {
target: this.element,
props: {
props: this.props
}
});
}
updateProps(props: Partial<NodeViewProps>): void {
Object.assign(this.props, props);
this.destroy();
this.render();
}
updateAttributes(attributes: Record<string, string>): void {
Object.keys(attributes).forEach((key) => {
this.element.setAttribute(key, attributes[key]);
});
this.destroy();
this.render();
}
destroy(): void {
if (this.mnt) {
unmount(this.mnt);
} else {
unmount(this.component);
}
}
}
export default SvelteRenderer;
+54
View File
@@ -0,0 +1,54 @@
import type { Content, Editor } from '@tiptap/core';
import type { EditorState, Transaction } from '@tiptap/pm/state';
import type { EditorView } from '@tiptap/pm/view';
import type { Snippet } from 'svelte';
export interface EdraEditorProps {
content?: Content;
editable?: boolean;
editor?: Editor;
autofocus?: boolean;
onUpdate?: (args: { editor: Editor; transaction: Transaction }) => void;
class?: string;
}
export interface EditorProps {
// Content of the editor
content?: Content;
// Whether the toolbar should be visible
showToolBar?: boolean;
// Whether the editor is editable
editable?: boolean;
// Whether to enable localStorage persistence
enablePersistence?: boolean;
// localStorage key for content persistence
contentStorageKey?: string;
// localStorage key for editable state persistence
editableStorageKey?: string;
// CSS class for the editor wrapper
class?: string;
// CSS class for the editor itself
editorClass?: string;
// Callback functions
onContentChange?: (content: Content) => void;
onEditorReady?: (editor: Editor) => void;
onEditorDestroy?: () => void;
}
export interface EdraToolbarProps {
editor: Editor;
class?: string;
excludedCommands?: string[];
children?: Snippet<[]>;
}
export interface ShouldShowProps {
editor: Editor;
element: HTMLElement;
view: EditorView;
state: EditorState;
oldState?: EditorState;
from: number;
to: number;
}
@@ -0,0 +1,20 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core';
import MediaExtended from './MediaExtended.svelte';
const { ...rest }: NodeViewProps = $props();
let mediaRef = $state<HTMLElement>();
</script>
<MediaExtended bind:mediaRef {...rest}>
{@const node = rest.node}
<audio
bind:this={mediaRef}
src={node.attrs.src}
controls
title={node.attrs.title}
class="m-0 w-full"
>
</audio>
</MediaExtended>
@@ -0,0 +1,22 @@
<script lang="ts">
import MediaPlaceHolder from '../../components/MediaPlaceHolder.svelte';
import type { NodeViewProps } from '@tiptap/core';
const { editor }: NodeViewProps = $props();
import Audio from '@lucide/svelte/icons/audio-lines';
import { buttonVariants } from '$src/base/button';
function handleClick() {
const audioUrl = prompt('Please enter the audio URL');
if (audioUrl) {
editor.chain().focus().setAudio(audioUrl).run();
}
}
</script>
<MediaPlaceHolder
class={buttonVariants({ variant: 'secondary', class: 'my-2 w-full justify-start p-6' })}
icon={Audio}
title="Insert an audio"
onClick={handleClick}
/>
@@ -0,0 +1,73 @@
<script lang="ts">
import { NodeViewWrapper, NodeViewContent } from 'svelte-tiptap';
import type { NodeViewProps } from '@tiptap/core';
import { Button, buttonVariants } from '$src/base/button';
const { node, updateAttributes, extension }: NodeViewProps = $props();
import * as DropdownMenu from '$src/base/dropdown-menu';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import Check from '@lucide/svelte/icons/check';
import Copy from '@lucide/svelte/icons/copy';
let preRef = $state<HTMLPreElement>();
let isCopying = $state(false);
const languages: string[] = extension.options.lowlight.listLanguages().sort();
let defaultLanguage = $state(node.attrs.language);
function copyCode() {
if (!preRef) return;
isCopying = true;
navigator.clipboard.writeText(preRef.innerText);
setTimeout(() => {
isCopying = false;
}, 1000);
}
</script>
<NodeViewWrapper class="code-wrapper" draggable={false} spellcheck={false}>
<div class="code-wrapper-tile" contenteditable="false">
<DropdownMenu.Root>
<DropdownMenu.Trigger
contenteditable="false"
class={buttonVariants({
variant: 'ghost',
size: 'sm',
class: 'text-muted-foreground h-4 rounded px-1 py-2 text-xs'
})}
>{defaultLanguage}
<ChevronDown class="!size-2" />
</DropdownMenu.Trigger>
<DropdownMenu.Content class="h-96 w-40 overflow-auto" contenteditable="false">
{#each languages as language (language)}
<DropdownMenu.Item
contenteditable="false"
data-current={defaultLanguage === language}
class="data-[current=true]:bg-muted"
textValue={language}
onclick={() => {
defaultLanguage = language;
updateAttributes({ language: defaultLanguage });
}}
>
<span>{language}</span>
{#if defaultLanguage === language}
<Check class="ml-auto" />
{/if}
</DropdownMenu.Item>
{/each}
</DropdownMenu.Content>
</DropdownMenu.Root>
<Button variant="ghost" class="text-muted-foreground size-4 p-0" onclick={copyCode}>
{#if isCopying}
<Check class="size-3 text-green-500" />
{:else}
<Copy class="size-3" />
{/if}
</Button>
</div>
<pre bind:this={preRef} draggable={false}>
<NodeViewContent as="code" class={`language-${defaultLanguage}`} {...node.attrs} />
</pre>
</NodeViewWrapper>
@@ -0,0 +1,23 @@
<script lang="ts">
import * as Tooltip from '$src/base/tooltip';
import type { Snippet } from 'svelte';
interface Props {
tooltip: string;
children: Snippet<[]>;
shortCut?: string;
}
const { tooltip, children, shortCut }: Props = $props();
</script>
<Tooltip.Provider delayDuration={100}>
<Tooltip.Root>
<Tooltip.Trigger>
{@render children()}
</Tooltip.Trigger>
<Tooltip.Content>
<span>{tooltip}</span>
<span class="text-muted-foreground">{shortCut}</span>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
@@ -0,0 +1,13 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core';
import MediaExtended from './MediaExtended.svelte';
const { ...rest }: NodeViewProps = $props();
let mediaRef = $state<HTMLElement>();
</script>
<MediaExtended bind:mediaRef {...rest}>
{@const node = rest.node}
<iframe bind:this={mediaRef} class="m-0 w-full" {...node.attrs}> </iframe>
</MediaExtended>
@@ -0,0 +1,22 @@
<script lang="ts">
import MediaPlaceHolder from '../../components/MediaPlaceHolder.svelte';
import type { NodeViewProps } from '@tiptap/core';
const { editor }: NodeViewProps = $props();
import Audio from '@lucide/svelte/icons/code-xml';
import { buttonVariants } from '$src/base/button';
function handleClick() {
const iframUrl = prompt('Please enter the IFrame URL');
if (iframUrl) {
editor.chain().focus().setIframe({ src: iframUrl }).run();
}
}
</script>
<MediaPlaceHolder
class={buttonVariants({ variant: 'secondary', class: 'my-2 w-full justify-start p-6' })}
icon={Audio}
title="Insert an iframe"
onClick={handleClick}
/>
@@ -0,0 +1,19 @@
<script lang="ts">
import type { NodeViewProps } from '@tiptap/core';
import MediaExtended from './MediaExtended.svelte';
const { ...rest }: NodeViewProps = $props();
let mediaRef = $state<HTMLElement>();
</script>
<MediaExtended bind:mediaRef {...rest}>
{@const node = rest.node}
<img
bind:this={mediaRef}
src={node.attrs.src}
alt={node.attrs.alt}
title={node.attrs.title}
class="m-0 object-cover"
/>
</MediaExtended>
@@ -0,0 +1,22 @@
<script lang="ts">
import MediaPlaceHolder from '../../components/MediaPlaceHolder.svelte';
import type { NodeViewProps } from '@tiptap/core';
const { editor }: NodeViewProps = $props();
import Image from '@lucide/svelte/icons/image';
import { buttonVariants } from '$src/base/button';
function handleClick() {
const imageUrl = prompt('Please enter the image URL');
if (imageUrl) {
editor.chain().focus().setImage({ src: imageUrl }).run();
}
}
</script>
<MediaPlaceHolder
class={buttonVariants({ variant: 'secondary', class: 'my-2 w-full justify-start p-6' })}
icon={Image}
title="Insert an image"
onClick={handleClick}
/>
@@ -0,0 +1,270 @@
<script lang="ts">
import { onDestroy, onMount, type Snippet } from 'svelte';
import { NodeViewWrapper } from 'svelte-tiptap';
import type { NodeViewProps } from '@tiptap/core';
import { cn } from '$src/tools';
import { Button, buttonVariants } from '$src/base/button';
import AlignCenter from '@lucide/svelte/icons/align-center';
import AlignLeft from '@lucide/svelte/icons/align-left';
import AlignRight from '@lucide/svelte/icons/align-right';
import EllipsisVertical from '@lucide/svelte/icons/ellipsis-vertical';
import CopyIcon from '@lucide/svelte/icons/copy';
import Fullscreen from '@lucide/svelte/icons/fullscreen';
import Trash from '@lucide/svelte/icons/trash';
import Captions from '@lucide/svelte/icons/captions';
import * as DropdownMenu from '$src/base/dropdown-menu';
import { duplicateContent } from '../../utils';
interface MediaExtendedProps extends NodeViewProps {
children: Snippet<[]>;
mediaRef?: HTMLElement;
}
const {
node,
editor,
selected,
deleteNode,
updateAttributes,
children,
mediaRef = $bindable()
}: MediaExtendedProps = $props();
const minWidthPercent = 15;
const maxWidthPercent = 100;
let nodeRef = $state<HTMLElement>();
let resizing = $state(false);
let resizingInitialWidthPercent = $state(0);
let resizingInitialMouseX = $state(0);
let resizingPosition = $state<'left' | 'right'>('left');
let openedMore = $state(false);
function handleResizingPosition(e: MouseEvent, position: 'left' | 'right') {
startResize(e);
resizingPosition = position;
}
function startResize(e: MouseEvent) {
e.preventDefault();
resizing = true;
resizingInitialMouseX = e.clientX;
if (mediaRef && nodeRef?.parentElement) {
const currentWidth = mediaRef.offsetWidth;
const parentWidth = nodeRef.parentElement.offsetWidth;
resizingInitialWidthPercent = (currentWidth / parentWidth) * 100;
}
}
function resize(e: MouseEvent) {
if (!resizing || !nodeRef?.parentElement) return;
let dx = e.clientX - resizingInitialMouseX;
if (resizingPosition === 'left') {
dx = resizingInitialMouseX - e.clientX;
}
const parentWidth = nodeRef.parentElement.offsetWidth;
const deltaPercent = (dx / parentWidth) * 100;
const newWidthPercent = Math.max(
Math.min(resizingInitialWidthPercent + deltaPercent, maxWidthPercent),
minWidthPercent
);
updateAttributes({ width: `${newWidthPercent}%` });
}
function endResize() {
resizing = false;
resizingInitialMouseX = 0;
resizingInitialWidthPercent = 0;
}
function handleTouchStart(e: TouchEvent, position: 'left' | 'right') {
e.preventDefault();
resizing = true;
resizingPosition = position;
resizingInitialMouseX = e.touches[0].clientX;
if (mediaRef && nodeRef?.parentElement) {
const currentWidth = mediaRef.offsetWidth;
const parentWidth = nodeRef.parentElement.offsetWidth;
resizingInitialWidthPercent = (currentWidth / parentWidth) * 100;
}
}
function handleTouchMove(e: TouchEvent) {
if (!resizing || !nodeRef?.parentElement) return;
let dx = e.touches[0].clientX - resizingInitialMouseX;
if (resizingPosition === 'left') {
dx = resizingInitialMouseX - e.touches[0].clientX;
}
const parentWidth = nodeRef.parentElement.offsetWidth;
const deltaPercent = (dx / parentWidth) * 100;
const newWidthPercent = Math.max(
Math.min(resizingInitialWidthPercent + deltaPercent, maxWidthPercent),
minWidthPercent
);
updateAttributes({ width: `${newWidthPercent}%` });
}
function handleTouchEnd() {
resizing = false;
resizingInitialMouseX = 0;
resizingInitialWidthPercent = 0;
}
onMount(() => {
// Attach id to nodeRef
nodeRef = document.getElementById('resizable-container-media') as HTMLDivElement;
// Mouse events
window.addEventListener('mousemove', resize);
window.addEventListener('mouseup', endResize);
// Touch events
window.addEventListener('touchmove', handleTouchMove);
window.addEventListener('touchend', handleTouchEnd);
});
onDestroy(() => {
window.removeEventListener('mousemove', resize);
window.removeEventListener('mouseup', endResize);
window.removeEventListener('touchmove', handleTouchMove);
window.removeEventListener('touchend', handleTouchEnd);
});
</script>
<!-- !FIXME: For some wierd reason, the change in `node.attrs.align` is not reactive to NodeViewWrapper
So for now, we'll re-render it when the align changes.
-->
{#key node.attrs.align}
<NodeViewWrapper
id="resizable-container-media"
class={cn(
'relative my-2 flex flex-col rounded-md border-2 border-transparent',
selected ? 'border-muted-foreground' : '',
node.attrs.align === 'left' && 'left-0 -translate-x-0',
node.attrs.align === 'center' && 'left-1/2 -translate-x-1/2',
node.attrs.align === 'right' && 'left-full -translate-x-full'
)}
style={`width: ${node.attrs.width}`}
>
<div class={cn('group relative flex flex-col rounded-md', resizing && '')}>
{@render children()}
{#if editor.isEditable}
<div
role="button"
tabindex="0"
aria-label="Back"
class="absolute inset-y-0 z-20 flex w-[25px] cursor-col-resize items-center justify-start p-2"
style="left: 0px"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'left');
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'left');
}}
>
<div
class="bg-muted z-20 h-[70px] w-1 rounded-xl border opacity-0 transition-all group-hover:opacity-100"
></div>
</div>
<div
role="button"
tabindex="0"
aria-label="Back"
class="absolute inset-y-0 z-20 flex w-[25px] cursor-col-resize items-center justify-end p-2"
style="right: 0px"
onmousedown={(event: MouseEvent) => {
handleResizingPosition(event, 'right');
}}
ontouchstart={(event: TouchEvent) => {
handleTouchStart(event, 'right');
}}
>
<div
class="bg-muted z-20 h-[70px] w-1 rounded-xl border opacity-0 transition-all group-hover:opacity-100"
></div>
</div>
<div
class={cn(
'bg-background/50 absolute -top-2 left-[calc(50%-3rem)] flex items-center gap-1 rounded border p-1 opacity-0 backdrop-blur-sm transition-opacity',
!resizing && 'group-hover:opacity-100',
openedMore && 'opacity-100'
)}
>
<Button
variant="ghost"
class={cn('size-6 p-0', node.attrs.align === 'left' && 'bg-muted')}
onclick={() => updateAttributes({ align: 'left' })}
title="Align Left"
>
<AlignLeft class="size-4" />
</Button>
<Button
variant="ghost"
class={cn('size-6 p-0', node.attrs.align === 'center' && 'bg-muted')}
onclick={() => updateAttributes({ align: 'center' })}
title="Align Center"
>
<AlignCenter class="size-4" />
</Button>
<Button
variant="ghost"
class={cn('size-6 p-0', node.attrs.align === 'right' && 'bg-muted')}
onclick={() => updateAttributes({ align: 'right' })}
title="Align Right"
>
<AlignRight class="size-4" />
</Button>
<DropdownMenu.Root bind:open={openedMore} onOpenChange={(value) => (openedMore = value)}>
<DropdownMenu.Trigger
class={buttonVariants({ variant: 'ghost', class: 'size-6 p-0' })}
title="More Options"
>
<EllipsisVertical class="size-4" />
</DropdownMenu.Trigger>
<DropdownMenu.Content align="start" alignOffset={-90} class="mt-1 overflow-auto text-sm">
<DropdownMenu.Item
onclick={() => {
if (node.attrs.title === null || node.attrs.title.trim() === '')
updateAttributes({
title: 'Image Caption'
});
}}
>
<Captions class="mr-1 size-4" /> Caption
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
duplicateContent(editor, node);
}}
>
<CopyIcon class="mr-1 size-4" /> Duplicate
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
updateAttributes({
width: '100%'
});
}}
>
<Fullscreen class="mr-1 size-4" /> Full Screen
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => {
deleteNode();
}}
class="text-destructive"
>
<Trash class="mr-1 size-4" /> Delete
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</div>
{/if}
</div>
</NodeViewWrapper>
{/key}

Some files were not shown because too many files have changed in this diff Show More