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:
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
|
||||
```
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+12
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@cio/tsconfig",
|
||||
"version": "0.0.0",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"publishConfig": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from "./input.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Input,
|
||||
};
|
||||
@@ -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}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Root from "./label.svelte";
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Label,
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
@@ -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} />
|
||||
@@ -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>
|
||||
@@ -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} />
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { default as Editor } from './editor.svelte';
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user