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 <tvnji01@gmail.com> * 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 <tvnji01@gmail.com>
This commit is contained in:
@@ -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}
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$src/base/button';
|
||||
import { cn } from '$src/tools';
|
||||
|
||||
interface Props {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
props: Record<string, any>;
|
||||
}
|
||||
|
||||
const { props }: Props = $props();
|
||||
|
||||
let scrollContainer = $state<HTMLElement | null>(null);
|
||||
|
||||
let selectedGroupIndex = $state<number>(0);
|
||||
let selectedCommandIndex = $state<number>(0);
|
||||
|
||||
const items = $derived.by(() => props.items);
|
||||
|
||||
$effect(() => {
|
||||
if (items) {
|
||||
selectedGroupIndex = 0;
|
||||
selectedCommandIndex = 0;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const activeItem = document.getElementById(`${selectedGroupIndex}-${selectedCommandIndex}`);
|
||||
if (activeItem !== null && scrollContainer !== null) {
|
||||
const offsetTop = activeItem.offsetTop;
|
||||
const offsetHeight = activeItem.offsetHeight;
|
||||
scrollContainer.scrollTop = offsetTop - offsetHeight;
|
||||
}
|
||||
});
|
||||
|
||||
const selectItem = (groupIndex: number, commandIndex: number) => {
|
||||
const command = props.items[groupIndex].commands[commandIndex];
|
||||
props.command(command);
|
||||
};
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown' || ((e.ctrlKey || e.metaKey) && e.key === 'j') || e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
if (!props.items.length) {
|
||||
return false;
|
||||
}
|
||||
const commands = props.items[selectedGroupIndex].commands;
|
||||
let newCommandIndex = selectedCommandIndex + 1;
|
||||
let newGroupIndex = selectedGroupIndex;
|
||||
if (commands.length - 1 < newCommandIndex) {
|
||||
newCommandIndex = 0;
|
||||
newGroupIndex = selectedGroupIndex + 1;
|
||||
}
|
||||
|
||||
if (props.items.length - 1 < newGroupIndex) {
|
||||
newGroupIndex = 0;
|
||||
}
|
||||
selectedCommandIndex = newCommandIndex;
|
||||
selectedGroupIndex = newGroupIndex;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowUp' || ((e.ctrlKey || e.metaKey) && e.key === 'k')) {
|
||||
e.preventDefault();
|
||||
if (!props.items.length) {
|
||||
return false;
|
||||
}
|
||||
let newCommandIndex = selectedCommandIndex - 1;
|
||||
let newGroupIndex = selectedGroupIndex;
|
||||
if (newCommandIndex < 0) {
|
||||
newGroupIndex = selectedGroupIndex - 1;
|
||||
newCommandIndex = props.items[newGroupIndex]?.commands.length - 1 || 0;
|
||||
}
|
||||
if (newGroupIndex < 0) {
|
||||
newGroupIndex = props.items.length - 1;
|
||||
newCommandIndex = props.items[newGroupIndex].commands.length - 1;
|
||||
}
|
||||
selectedCommandIndex = newCommandIndex;
|
||||
selectedGroupIndex = newGroupIndex;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (!props.items.length || selectedGroupIndex === -1 || selectedCommandIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
selectItem(selectedGroupIndex, selectedCommandIndex);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeyDown} />
|
||||
|
||||
{#if items.length}
|
||||
<div
|
||||
bind:this={scrollContainer}
|
||||
class="bg-popover flex max-h-80 flex-col gap-1 overflow-auto scroll-smooth rounded border"
|
||||
>
|
||||
{#each items as grp, groupIndex (groupIndex)}
|
||||
<span class="text-muted-foreground p-2 text-xs">{grp.title}</span>
|
||||
|
||||
{#each grp.commands as command, commandIndex (commandIndex)}
|
||||
{@const Icon = command.icon}
|
||||
{@const isActive = selectedGroupIndex === groupIndex && selectedCommandIndex === commandIndex}
|
||||
<Button
|
||||
id={`${groupIndex}-${commandIndex}`}
|
||||
variant="ghost"
|
||||
class={cn('h-8 w-full justify-start gap-2 rounded-sm', isActive && 'bg-muted')}
|
||||
onclick={() => selectItem(groupIndex, commandIndex)}
|
||||
>
|
||||
<Icon />
|
||||
<span>{command.tooltip}</span>
|
||||
</Button>
|
||||
{/each}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$src/base/button';
|
||||
import type { EdraToolBarCommands } from '../../commands/types';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import EdraToolTip from './EdraToolTip.svelte';
|
||||
import { cn } from '$src/tools';
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
command: EdraToolBarCommands;
|
||||
}
|
||||
|
||||
const { editor, command }: Props = $props();
|
||||
</script>
|
||||
|
||||
<EdraToolTip tooltip={command.tooltip ?? ''} shortCut={command.shortCut ?? ''}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={cn(command.isActive?.(editor) && 'bg-muted')}
|
||||
onclick={() => command.onClick?.(editor)}
|
||||
disabled={command.clickable ? !command.clickable(editor) : false}
|
||||
>
|
||||
{@const Icon = command.icon}
|
||||
<Icon />
|
||||
</Button>
|
||||
</EdraToolTip>
|
||||
@@ -0,0 +1,21 @@
|
||||
<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}
|
||||
<video
|
||||
bind:this={mediaRef}
|
||||
src={node.attrs.src}
|
||||
controls
|
||||
title={node.attrs.title}
|
||||
class="m-0 object-cover"
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
</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 Video from '@lucide/svelte/icons/video';
|
||||
import { buttonVariants } from '$src/base/button';
|
||||
|
||||
function handleClick() {
|
||||
const videoUrl = prompt('Please enter the video URL');
|
||||
if (videoUrl) {
|
||||
editor.chain().focus().setVideo(videoUrl).run();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<MediaPlaceHolder
|
||||
class={buttonVariants({ variant: 'secondary', class: 'my-2 w-full justify-start p-6' })}
|
||||
icon={Video}
|
||||
title="Insert a video"
|
||||
onClick={handleClick}
|
||||
/>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import * as DropdownMenu from '$src/base/dropdown-menu';
|
||||
import commands from '../../../commands/toolbar-commands';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import AlignLeft from '@lucide/svelte/icons/align-left';
|
||||
import EdraToolTip from '../EdraToolTip.svelte';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import { buttonVariants } from '$src/base/button';
|
||||
import { cn } from '$src/tools';
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
const { editor }: Props = $props();
|
||||
|
||||
const alignments = commands['alignment'];
|
||||
|
||||
const isActive = $derived.by(() => {
|
||||
return alignments.find((alignment) => alignment.isActive?.(editor)) !== undefined;
|
||||
});
|
||||
|
||||
const AlignMentIcon = $derived.by(() => {
|
||||
const a = alignments.find((alignment) => alignment.isActive?.(editor));
|
||||
if (a) return a.icon;
|
||||
else return AlignLeft;
|
||||
});
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<EdraToolTip tooltip="Alignment">
|
||||
<div
|
||||
class={buttonVariants({
|
||||
variant: 'ghost',
|
||||
size: 'icon',
|
||||
class: cn('gap-0')
|
||||
})}
|
||||
class:bg-muted={isActive}
|
||||
>
|
||||
<AlignMentIcon />
|
||||
<ChevronDown class="text-muted-foreground !size-2" />
|
||||
</div>
|
||||
</EdraToolTip>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content portalProps={{ disabled: true, to: undefined }}>
|
||||
{#each alignments as alignment (alignment)}
|
||||
{@const Icon = alignment.icon}
|
||||
<DropdownMenu.Item onclick={() => alignment.onClick?.(editor)}>
|
||||
<Icon />
|
||||
<span>{alignment.tooltip}</span>
|
||||
<DropdownMenu.Shortcut>
|
||||
{alignment.shortCut}
|
||||
</DropdownMenu.Shortcut>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$src/base/button';
|
||||
import * as DropdownMenu from '$src/base/dropdown-menu';
|
||||
import { Editor } from '@tiptap/core';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import { cn } from '$src/tools';
|
||||
import EdraToolTip from '../EdraToolTip.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
const { class: className = '', editor }: Props = $props();
|
||||
|
||||
const FONT_SIZE = [
|
||||
{ label: 'Tiny', value: '0.7rem' },
|
||||
{ label: 'Smaller', value: '0.75rem' },
|
||||
{ label: 'Small', value: '0.9rem' },
|
||||
{ label: 'Default', value: '' },
|
||||
{ label: 'Large', value: '1.25rem' },
|
||||
{ label: 'Extra Large', value: '1.5rem' }
|
||||
];
|
||||
|
||||
let currentSize = $derived.by(() => editor.getAttributes('textStyle').fontSize || '');
|
||||
|
||||
const currentLabel = $derived.by(() => {
|
||||
const l = FONT_SIZE.find((f) => f.value === currentSize);
|
||||
if (l) return l.label.split(' ')[0];
|
||||
return 'Medium';
|
||||
});
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<EdraToolTip tooltip="Font Size">
|
||||
<Button variant="ghost" class={cn('gap-0.5 !px-2', className)}>
|
||||
<span>{currentLabel}</span>
|
||||
<ChevronDown class="text-muted-foreground !size-2" />
|
||||
</Button>
|
||||
</EdraToolTip>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content class="h-fit w-fit" portalProps={{ disabled: true, to: undefined }}>
|
||||
{#each FONT_SIZE as fontSize (fontSize)}
|
||||
<DropdownMenu.Item
|
||||
onclick={() => {
|
||||
editor.chain().focus().setFontSize(fontSize.value).run();
|
||||
}}
|
||||
style={`font-size: ${fontSize.value}`}>{fontSize.label}</DropdownMenu.Item
|
||||
>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
@@ -0,0 +1,59 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import * as DropdownMenu from '$src/base/dropdown-menu';
|
||||
import Heading from '@lucide/svelte/icons/heading';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import commands from '../../../commands/toolbar-commands';
|
||||
import { cn } from '$src/tools';
|
||||
import EdraToolTip from '../EdraToolTip.svelte';
|
||||
import Paragraph from '@lucide/svelte/icons/pilcrow';
|
||||
import { buttonVariants } from '$src/base/button';
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
const { editor }: Props = $props();
|
||||
|
||||
const headings = commands['headings'];
|
||||
|
||||
const isActive = $derived.by(() => {
|
||||
return headings.find((h) => h.isActive?.(editor)) !== undefined;
|
||||
});
|
||||
|
||||
const HeadingIcon = $derived.by(() => {
|
||||
const h = headings.find((h) => h.isActive?.(editor));
|
||||
return h ? h.icon : Heading;
|
||||
});
|
||||
</script>
|
||||
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
<EdraToolTip tooltip="Headings">
|
||||
<div
|
||||
class={buttonVariants({
|
||||
variant: 'ghost',
|
||||
size: 'icon',
|
||||
class: cn('gap-0')
|
||||
})}
|
||||
class:bg-muted={isActive}
|
||||
>
|
||||
<HeadingIcon />
|
||||
<ChevronDown class="text-muted-foreground !size-2" />
|
||||
</div>
|
||||
</EdraToolTip>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content portalProps={{ to: undefined, disabled: true }}>
|
||||
<DropdownMenu.Item onclick={() => editor.chain().focus().setParagraph().run()}>
|
||||
<Paragraph />
|
||||
<span>Paragraph</span>
|
||||
</DropdownMenu.Item>
|
||||
{#each headings as heading (heading)}
|
||||
{@const Icon = heading.icon}
|
||||
<DropdownMenu.Item onclick={() => heading.onClick?.(editor)}>
|
||||
<Icon />
|
||||
<span>{heading.tooltip}</span>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts">
|
||||
import { Button, buttonVariants } from '$src/base/button';
|
||||
import * as Popover from '$src/base/popover';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import { cn } from '$src/tools';
|
||||
import EdraToolTip from '../EdraToolTip.svelte';
|
||||
|
||||
interface Props {
|
||||
class?: string;
|
||||
editor: Editor;
|
||||
}
|
||||
const { class: className = '', editor }: Props = $props();
|
||||
|
||||
const colors = [
|
||||
{ label: 'Default', value: '' },
|
||||
{ label: 'Blue', value: '#0000FF' },
|
||||
{ label: 'Brown', value: '#A52A2A' },
|
||||
{ label: 'Green', value: '#008000' },
|
||||
{ label: 'Grey', value: '#808080' },
|
||||
{ label: 'Orange', value: '#FFA500' },
|
||||
{ label: 'Pink', value: '#FFC0CB' },
|
||||
{ label: 'Purple', value: '#800080' },
|
||||
{ label: 'Red', value: '#FF0000' },
|
||||
{ label: 'Yellow', value: '#FFFF00' }
|
||||
];
|
||||
|
||||
const currentColor = $derived.by(() => editor.getAttributes('textStyle').color);
|
||||
const currentHighlight = $derived.by(() => editor.getAttributes('highlight').color);
|
||||
</script>
|
||||
|
||||
<Popover.Root>
|
||||
<Popover.Trigger>
|
||||
<EdraToolTip tooltip="Quick Colors">
|
||||
<div
|
||||
class={buttonVariants({
|
||||
variant: 'ghost',
|
||||
size: 'icon',
|
||||
class: cn('gap-0.5', className)
|
||||
})}
|
||||
style={`color: ${currentColor}; background-color: ${currentHighlight}50;`}
|
||||
>
|
||||
<span>A</span>
|
||||
<ChevronDown class="text-muted-foreground !size-2" />
|
||||
</div>
|
||||
</EdraToolTip>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="size-fit shadow-lg" portalProps={{ disabled: true, to: undefined }}>
|
||||
<div class="text-muted-foreground my-2 text-xs">Text Colors</div>
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
{#each colors as color (color)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
class={cn(
|
||||
`size-6 border-0 p-0 font-normal`,
|
||||
editor.isActive('textStyle', { color: color.value }) && 'border-2 font-extrabold',
|
||||
color.value === '' && 'border'
|
||||
)}
|
||||
style={`color: ${color.value}; background-color: ${color.value}50; border-color: ${color.value};`}
|
||||
title={color.label}
|
||||
onclick={() => {
|
||||
if (color.value === '' || color.label === 'Default') editor.chain().focus().unsetColor().run();
|
||||
else
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setColor(currentColor === color.value ? '' : color.value)
|
||||
.run();
|
||||
}}
|
||||
>
|
||||
A
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="text-muted-foreground my-2 text-xs">Highlight Colors</div>
|
||||
<div class="grid grid-cols-5 gap-2">
|
||||
{#each colors as color (color)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
class={cn(
|
||||
`size-6 border-0 p-0 font-normal`,
|
||||
editor.isActive('highlight', { color: color.value }) && 'border-2',
|
||||
color.value === '' && 'border'
|
||||
)}
|
||||
style={`background-color: ${color.value}50; border-color: ${color.value};`}
|
||||
title={color.label}
|
||||
onclick={() => {
|
||||
if (color.value === '' || color.label === 'Default') editor.chain().focus().unsetHighlight().run();
|
||||
else editor.chain().focus().toggleHighlight({ color: color.value }).run();
|
||||
}}>A</Button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$src/base/button';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import Search from '@lucide/svelte/icons/search';
|
||||
import * as Popover from '$src/base/popover';
|
||||
import { Input } from '$src/base/input';
|
||||
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import ChevronDown from '@lucide/svelte/icons/chevron-down';
|
||||
import ChevronRight from '@lucide/svelte/icons/chevron-right';
|
||||
import CaseSensitive from '@lucide/svelte/icons/case-sensitive';
|
||||
import Replace from '@lucide/svelte/icons/replace';
|
||||
import ReplaceAll from '@lucide/svelte/icons/replace-all';
|
||||
import { cn } from '$src/tools';
|
||||
import { slide } from 'svelte/transition';
|
||||
import EdraToolTip from '../EdraToolTip.svelte';
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
const { editor }: Props = $props();
|
||||
|
||||
let open = $state(false);
|
||||
let showMore = $state(false);
|
||||
|
||||
let searchText = $state('');
|
||||
let replaceText = $state('');
|
||||
let caseSensitive = $state(false);
|
||||
|
||||
let searchIndex = $derived(editor.storage?.searchAndReplace?.resultIndex);
|
||||
let searchCount = $derived(editor.storage?.searchAndReplace?.results.length);
|
||||
|
||||
function updateSearchTerm(clearIndex: boolean = false) {
|
||||
if (clearIndex) editor.commands.resetIndex();
|
||||
|
||||
editor.commands.setSearchTerm(searchText);
|
||||
editor.commands.setReplaceTerm(replaceText);
|
||||
editor.commands.setCaseSensitive(caseSensitive);
|
||||
}
|
||||
|
||||
function goToSelection() {
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
const position = results[resultIndex];
|
||||
if (!position) return;
|
||||
editor.commands.setTextSelection(position);
|
||||
const { node } = editor.view.domAtPos(editor.state.selection.anchor);
|
||||
if (node instanceof HTMLElement) node.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
function replace() {
|
||||
editor.commands.replace();
|
||||
goToSelection();
|
||||
}
|
||||
|
||||
const next = () => {
|
||||
editor.commands.nextSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
editor.commands.previousSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
searchText = '';
|
||||
replaceText = '';
|
||||
caseSensitive = false;
|
||||
editor.commands.resetIndex();
|
||||
};
|
||||
|
||||
const replaceAll = () => editor.commands.replaceAll();
|
||||
</script>
|
||||
|
||||
<Popover.Root
|
||||
bind:open
|
||||
onOpenChange={(value) => {
|
||||
if (value === false) {
|
||||
clear();
|
||||
updateSearchTerm();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Popover.Trigger>
|
||||
<EdraToolTip tooltip="Search and Replace">
|
||||
<Button variant="ghost" size="icon" class="gap-0.5">
|
||||
<Search />
|
||||
<ChevronDown class="text-muted-foreground !size-2" />
|
||||
</Button>
|
||||
</EdraToolTip>
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="flex w-fit items-center gap-1 p-2" portalProps={{ disabled: true, to: undefined }}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={cn('transition-transform', showMore && 'bg-muted rotate-90')}
|
||||
onclick={() => (showMore = !showMore)}
|
||||
title="Show More"
|
||||
>
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
<div class="flex size-full flex-col gap-1">
|
||||
<div class="flex w-full items-center gap-1">
|
||||
<Input placeholder="Search..." bind:value={searchText} oninput={() => updateSearchTerm()} class="w-40" />
|
||||
<span class="text-muted-foreground text-sm">{searchCount > 0 ? searchIndex + 1 : 0}/{searchCount} </span>
|
||||
<EdraToolTip tooltip="Case Sensitive">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class={cn(caseSensitive && 'bg-muted')}
|
||||
onclick={() => {
|
||||
caseSensitive = !caseSensitive;
|
||||
updateSearchTerm();
|
||||
}}
|
||||
>
|
||||
<CaseSensitive />
|
||||
</Button>
|
||||
</EdraToolTip>
|
||||
<EdraToolTip tooltip="Go to previous">
|
||||
<Button variant="ghost" size="icon" onclick={previous} title="Previous">
|
||||
<ArrowLeft />
|
||||
</Button>
|
||||
</EdraToolTip>
|
||||
<EdraToolTip tooltip="Go to next">
|
||||
<Button variant="ghost" size="icon" onclick={next} title="Next">
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
</EdraToolTip>
|
||||
</div>
|
||||
{#if showMore}
|
||||
<div transition:slide class="flex w-full items-center gap-1">
|
||||
<Input placeholder="Replace..." bind:value={replaceText} oninput={() => updateSearchTerm()} class="w-40" />
|
||||
<EdraToolTip tooltip="Replace">
|
||||
<Button variant="ghost" size="icon" onclick={replace}>
|
||||
<Replace />
|
||||
</Button>
|
||||
</EdraToolTip>
|
||||
<EdraToolTip tooltip="Replace All">
|
||||
<Button variant="ghost" size="icon" onclick={replaceAll}>
|
||||
<ReplaceAll />
|
||||
</Button>
|
||||
</EdraToolTip>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
@@ -0,0 +1,103 @@
|
||||
<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';
|
||||
import { Button } from '$src/base/button';
|
||||
import type { Node } from '@tiptap/pm/model';
|
||||
import * as DropdownMenu from '$src/base/dropdown-menu';
|
||||
import RemoveFormatting from '@lucide/svelte/icons/remove-formatting';
|
||||
import Duplicate from '@lucide/svelte/icons/copy';
|
||||
import Clipboard from '@lucide/svelte/icons/clipboard';
|
||||
import Delete from '@lucide/svelte/icons/trash-2';
|
||||
import { NodeSelection } from '@tiptap/pm/state';
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
const { editor }: Props = $props();
|
||||
|
||||
let currentNode: Node | null = $state(null);
|
||||
let currentNodePos: number = $state(-1);
|
||||
let open = $state(false);
|
||||
|
||||
const pluginKey = 'globalDragHandle';
|
||||
|
||||
onMount(() => {
|
||||
const plugin = DragHandlePlugin({
|
||||
pluginKey: pluginKey,
|
||||
dragHandleWidth: 20,
|
||||
scrollTreshold: 100,
|
||||
dragHandleSelector: '.drag-handle',
|
||||
excludedTags: ['pre', 'code', 'table p'],
|
||||
customNodes: [],
|
||||
onMouseMove: onMouseMove
|
||||
});
|
||||
editor.registerPlugin(plugin);
|
||||
return () => editor.unregisterPlugin(pluginKey);
|
||||
});
|
||||
|
||||
const onMouseMove = (data: { node: Node; pos: number }) => {
|
||||
if (data.node) currentNode = data.node;
|
||||
currentNodePos = data.pos;
|
||||
};
|
||||
|
||||
const handleRemoveFormatting = () => {
|
||||
const chain = editor.chain();
|
||||
chain.setNodeSelection(currentNodePos).unsetAllMarks();
|
||||
chain.setParagraph();
|
||||
chain.run();
|
||||
};
|
||||
|
||||
const handleDuplicate = () => {
|
||||
editor.commands.setNodeSelection(currentNodePos);
|
||||
const selectedNode = editor.state.selection.$anchor.node(1) || (editor.state.selection as NodeSelection).node;
|
||||
editor
|
||||
.chain()
|
||||
.setMeta('hideDragHandle', true)
|
||||
.insertContentAt(currentNodePos + (currentNode?.nodeSize || 0), selectedNode.toJSON())
|
||||
.run();
|
||||
};
|
||||
|
||||
const handleCopyToClipboard = () => {
|
||||
editor.chain().setMeta('hideDragHandle', true).setNodeSelection(currentNodePos).run();
|
||||
/**
|
||||
* !FIXME: document.execCommand is deprecated, use navigator.clipboard.writeText instead
|
||||
*/
|
||||
document.execCommand('copy');
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
editor.chain().setMeta('hideDragHandle', true).setNodeSelection(currentNodePos).deleteSelection().run();
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="drag-handle">
|
||||
<Button variant="ghost" class="!size-6 rounded-sm p-0" onclick={() => (open = true)}>
|
||||
<GripVertical />
|
||||
</Button>
|
||||
<DropdownMenu.Root bind:open>
|
||||
<DropdownMenu.Trigger class="sr-only">
|
||||
<span>Drag Handle</span>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content>
|
||||
<DropdownMenu.Item onclick={handleRemoveFormatting}>
|
||||
<RemoveFormatting />
|
||||
Remove Formatting
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleDuplicate}>
|
||||
<Duplicate />
|
||||
Duplicate Node
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleCopyToClipboard}>
|
||||
<Clipboard />
|
||||
Copy to clipboard
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={handleDelete}>
|
||||
<Delete class="text-destructive" />
|
||||
Delete Node
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import type { EdraEditorProps } from '../types';
|
||||
import initEditor from '../editor';
|
||||
import { focusEditor } from '../utils';
|
||||
import { cn } from '$src/tools';
|
||||
import { ImagePlaceholder } from '../extensions/image/ImagePlaceholder';
|
||||
import ImagePlaceholderComp from './components/ImagePlaceholder.svelte';
|
||||
import { ImageExtended } from '../extensions/image/ImageExtended';
|
||||
import ImageExtendedComp from './components/ImageExtended.svelte';
|
||||
import { VideoPlaceholder } from '../extensions/video/VideoPlaceholder';
|
||||
import VideoPlaceHolderComp from './components/VideoPlaceholder.svelte';
|
||||
import { VideoExtended } from '../extensions/video/VideoExtended';
|
||||
import VideoExtendedComp from './components/VideoExtended.svelte';
|
||||
import { AudioPlaceholder } from '../extensions/audio/AudioPlaceholder';
|
||||
import { AudioExtended } from '../extensions/audio/AudiExtended';
|
||||
import AudioPlaceHolderComp from './components/AudioPlaceHolder.svelte';
|
||||
import AudioExtendedComp from './components/AudioExtended.svelte';
|
||||
import { IFramePlaceholder } from '../extensions/iframe/IFramePlaceholder';
|
||||
import { IFrameExtended } from '../extensions/iframe/IFrameExtended';
|
||||
import IFramePlaceHolderComp from './components/IFramePlaceHolder.svelte';
|
||||
import IFrameExtendedComp from './components/IFrameExtended.svelte';
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
|
||||
import { all, createLowlight } from 'lowlight';
|
||||
import { SvelteNodeViewRenderer } from 'svelte-tiptap';
|
||||
import CodeBlock from './components/CodeBlock.svelte';
|
||||
import TableCol from './menus/TableCol.svelte';
|
||||
import TableRow from './menus/TableRow.svelte';
|
||||
import Link from './menus/Link.svelte';
|
||||
import slashcommand from '../extensions/slash-command/slashcommand';
|
||||
import SlashCommandList from './components/SlashCommandList.svelte';
|
||||
|
||||
import '../editor.css';
|
||||
import './style.css';
|
||||
import '../onedark.css';
|
||||
|
||||
const lowlight = createLowlight(all);
|
||||
|
||||
/**
|
||||
* Bind the element to the editor
|
||||
*/
|
||||
let element = $state<HTMLElement>();
|
||||
let {
|
||||
editor = $bindable(),
|
||||
editable = true,
|
||||
content,
|
||||
onUpdate,
|
||||
autofocus = false,
|
||||
class: className
|
||||
}: EdraEditorProps = $props();
|
||||
|
||||
onMount(() => {
|
||||
editor = initEditor(
|
||||
element,
|
||||
content,
|
||||
[
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight
|
||||
}).extend({
|
||||
addNodeView() {
|
||||
return SvelteNodeViewRenderer(CodeBlock);
|
||||
}
|
||||
}),
|
||||
ImagePlaceholder(ImagePlaceholderComp),
|
||||
ImageExtended(ImageExtendedComp),
|
||||
VideoPlaceholder(VideoPlaceHolderComp),
|
||||
VideoExtended(VideoExtendedComp),
|
||||
AudioPlaceholder(AudioPlaceHolderComp),
|
||||
AudioExtended(AudioExtendedComp),
|
||||
IFramePlaceholder(IFramePlaceHolderComp),
|
||||
IFrameExtended(IFrameExtendedComp),
|
||||
slashcommand(SlashCommandList)
|
||||
],
|
||||
{
|
||||
onUpdate,
|
||||
onTransaction(props) {
|
||||
editor = undefined;
|
||||
editor = props.editor;
|
||||
},
|
||||
editable,
|
||||
autofocus
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (editor) editor.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if editor && !editor.isDestroyed}
|
||||
<Link {editor} />
|
||||
<TableCol {editor} />
|
||||
<TableRow {editor} />
|
||||
{/if}
|
||||
<div
|
||||
bind:this={element}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={(event) => focusEditor(editor, event)}
|
||||
onkeydown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
focusEditor(editor, event);
|
||||
}
|
||||
}}
|
||||
class={cn('edra-editor h-full w-full cursor-auto px-4 *:outline-none', className)}
|
||||
></div>
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as EdraEditor } from './editor.svelte';
|
||||
export { default as EdraToolBar } from './toolbar.svelte';
|
||||
export { default as EdraBubbleMenu } from './menus/Menu.svelte';
|
||||
export { default as EdraDragHandleExtended } from './drag-handle-extended.svelte';
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as EdraEditor } from './editor.svelte';
|
||||
export { default as EdraToolBar } from './toolbar.svelte';
|
||||
export { default as EdraBubbleMenu } from './menus/Menu.svelte';
|
||||
export { default as EdraDragHandleExtended } from './drag-handle-extended.svelte';
|
||||
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import type { ShouldShowProps } from '../../types';
|
||||
import BubbleMenu from '../../components/BubbleMenu.svelte';
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { Button } from '$src/base/button';
|
||||
import Copy from '@lucide/svelte/icons/copy';
|
||||
import Trash from '@lucide/svelte/icons/trash';
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
const { editor }: Props = $props();
|
||||
|
||||
let link = $derived.by(() => editor.getAttributes('link').href);
|
||||
</script>
|
||||
|
||||
<BubbleMenu
|
||||
{editor}
|
||||
pluginKey="link-bubble-menu"
|
||||
shouldShow={(props: ShouldShowProps) => {
|
||||
if (!props.editor.isEditable) return false;
|
||||
return props.editor.isActive('link');
|
||||
}}
|
||||
options={{
|
||||
strategy: 'fixed'
|
||||
}}
|
||||
class="bg-popover flex h-fit w-fit items-center gap-1 rounded border p-1 shadow-lg"
|
||||
>
|
||||
<Button variant="link" href={link} class="max-w-80 p-1" target="_blank">
|
||||
<span class="w-full overflow-hidden text-ellipsis">
|
||||
{link}
|
||||
</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
title="Copy Link"
|
||||
size="icon"
|
||||
class="z-50"
|
||||
onclick={() => {
|
||||
navigator.clipboard.writeText(link);
|
||||
}}
|
||||
>
|
||||
<Copy />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
title="Remove Link"
|
||||
size="icon"
|
||||
onclick={() => editor.chain().focus().extendMarkRange('link').unsetLink().run()}
|
||||
>
|
||||
<Trash />
|
||||
</Button>
|
||||
</BubbleMenu>
|
||||
@@ -0,0 +1,113 @@
|
||||
<script lang="ts">
|
||||
import commands from '../../commands/toolbar-commands';
|
||||
import BubbleMenu from '../../components/BubbleMenu.svelte';
|
||||
import type { EdraToolbarProps, ShouldShowProps } from '../../types';
|
||||
|
||||
import { cn } from '$src/tools';
|
||||
import { isTextSelection } from '@tiptap/core';
|
||||
import Alignment from '../components/toolbar/Alignment.svelte';
|
||||
import FontSize from '../components/toolbar/FontSize.svelte';
|
||||
import Headings from '../components/toolbar/Headings.svelte';
|
||||
import QuickColors from '../components/toolbar/QuickColors.svelte';
|
||||
import ToolBarIcon from '../components/ToolBarIcon.svelte';
|
||||
|
||||
const {
|
||||
editor,
|
||||
class: className,
|
||||
excludedCommands = ['undo-redo', 'media', 'lists', 'table'],
|
||||
children
|
||||
}: EdraToolbarProps = $props();
|
||||
|
||||
const toolbarCommands = Object.keys(commands).filter((key) => !excludedCommands?.includes(key));
|
||||
|
||||
let isDragging = $state(false);
|
||||
|
||||
editor.view.dom.addEventListener('dragstart', () => {
|
||||
isDragging = true;
|
||||
});
|
||||
|
||||
editor.view.dom.addEventListener('drop', () => {
|
||||
isDragging = true;
|
||||
|
||||
// Allow some time for the drop action to complete before re-enabling
|
||||
setTimeout(() => {
|
||||
isDragging = false;
|
||||
}, 100); // Adjust delay if needed
|
||||
});
|
||||
|
||||
function shouldShow(props: ShouldShowProps) {
|
||||
if (!props.editor.isEditable) return false;
|
||||
const { view, editor } = props;
|
||||
if (!view || editor.view.dragging) {
|
||||
return false;
|
||||
}
|
||||
if (editor.isActive('link')) return false;
|
||||
if (editor.isActive('codeBlock')) return false;
|
||||
if (editor.isActive('image-placeholder')) return false;
|
||||
if (editor.isActive('video-placeholder')) return false;
|
||||
if (editor.isActive('audio-placeholder')) return false;
|
||||
if (editor.isActive('iframe-placeholder')) return false;
|
||||
const {
|
||||
state: {
|
||||
doc,
|
||||
selection,
|
||||
selection: { empty, from, to }
|
||||
}
|
||||
} = editor;
|
||||
// check if the selection is a table grip
|
||||
const domAtPos = view.domAtPos(from || 0).node as HTMLElement;
|
||||
const nodeDOM = view.nodeDOM(from || 0) as HTMLElement;
|
||||
const node = nodeDOM || domAtPos;
|
||||
|
||||
if (isTableGripSelected(node)) {
|
||||
return false;
|
||||
}
|
||||
// Sometime check for `empty` is not enough.
|
||||
// Doubleclick an empty paragraph returns a node size of 2.
|
||||
// So we check also for an empty text size.
|
||||
const isEmptyTextBlock = !doc.textBetween(from, to).length && isTextSelection(selection);
|
||||
if (empty || isEmptyTextBlock || !editor.isEditable) {
|
||||
return false;
|
||||
}
|
||||
return !isDragging && !editor.state.selection.empty;
|
||||
}
|
||||
|
||||
const isTableGripSelected = (node: HTMLElement) => {
|
||||
let container = node;
|
||||
while (container && !['TD', 'TH'].includes(container.tagName)) {
|
||||
container = container.parentElement!;
|
||||
}
|
||||
const gripColumn = container && container.querySelector && container.querySelector('a.grip-column.selected');
|
||||
const gripRow = container && container.querySelector && container.querySelector('a.grip-row.selected');
|
||||
if (gripColumn || gripRow) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<BubbleMenu
|
||||
{editor}
|
||||
pluginKey="link-bubble-menu"
|
||||
{shouldShow}
|
||||
class={cn('edra-bubble-menu bg-popover flex h-fit w-fit items-center rounded-lg p-0.5', className)}
|
||||
>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
{#each toolbarCommands.filter((c) => !excludedCommands?.includes(c)) as cmd (cmd)}
|
||||
{#if cmd === 'headings'}
|
||||
<Headings {editor} />
|
||||
{:else if cmd === 'alignment'}
|
||||
<Alignment {editor} />
|
||||
{:else}
|
||||
{@const commandGroup = commands[cmd]}
|
||||
{#each commandGroup as command (command)}
|
||||
<ToolBarIcon {editor} {command} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
<FontSize {editor} />
|
||||
<QuickColors {editor} />
|
||||
{/if}
|
||||
</BubbleMenu>
|
||||
@@ -0,0 +1,53 @@
|
||||
<script lang="ts">
|
||||
import type { ShouldShowProps } from '../../types';
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import ArrowLeftFromLine from '@lucide/svelte/icons/arrow-left-from-line';
|
||||
import ArrowRightFromLine from '@lucide/svelte/icons/arrow-right-from-line';
|
||||
|
||||
import Trash from '@lucide/svelte/icons/trash';
|
||||
import { Button } from '$src/base/button';
|
||||
import { isColumnGripSelected } from '../../extensions/table/utils';
|
||||
import EdraToolTip from '../components/EdraToolTip.svelte';
|
||||
import BubbleMenu from '../../components/BubbleMenu.svelte';
|
||||
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
const { editor }: Props = $props();
|
||||
</script>
|
||||
|
||||
<BubbleMenu
|
||||
{editor}
|
||||
pluginKey="table-col-menu"
|
||||
shouldShow={(props: ShouldShowProps) => {
|
||||
if (!props.editor.isEditable) return false;
|
||||
if (!props.state) {
|
||||
return false;
|
||||
}
|
||||
return isColumnGripSelected({
|
||||
editor: props.editor,
|
||||
view: props.view,
|
||||
state: props.state,
|
||||
from: props.from
|
||||
});
|
||||
}}
|
||||
class="bg-background flex h-fit w-fit items-center gap-1 rounded border shadow-lg"
|
||||
>
|
||||
<EdraToolTip tooltip="Add Column After">
|
||||
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().addColumnAfter().run()}>
|
||||
<ArrowRightFromLine />
|
||||
</Button>
|
||||
</EdraToolTip>
|
||||
<EdraToolTip tooltip="Add Column Before">
|
||||
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().addColumnBefore().run()}>
|
||||
<ArrowLeftFromLine />
|
||||
</Button>
|
||||
</EdraToolTip>
|
||||
|
||||
<EdraToolTip tooltip="Delete This Column">
|
||||
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().deleteColumn().run()}>
|
||||
<Trash />
|
||||
</Button>
|
||||
</EdraToolTip>
|
||||
</BubbleMenu>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type { ShouldShowProps } from '../../types';
|
||||
import { type Editor } from '@tiptap/core';
|
||||
import ArrowDownFromLine from '@lucide/svelte/icons/arrow-down-from-line';
|
||||
import ArrowUpFromLine from '@lucide/svelte/icons/arrow-up-from-line';
|
||||
import Trash from '@lucide/svelte/icons/trash';
|
||||
import { Button } from '$src/base/button';
|
||||
import { isRowGripSelected } from '../../extensions/table/utils';
|
||||
import EdraToolTip from '../components/EdraToolTip.svelte';
|
||||
import BubbleMenu from '../../components/BubbleMenu.svelte';
|
||||
interface Props {
|
||||
editor: Editor;
|
||||
}
|
||||
|
||||
const { editor }: Props = $props();
|
||||
</script>
|
||||
|
||||
<BubbleMenu
|
||||
{editor}
|
||||
pluginKey="table-row-menu"
|
||||
shouldShow={(props: ShouldShowProps) => {
|
||||
if (!props.editor.isEditable) return false;
|
||||
if (!props.state) {
|
||||
return false;
|
||||
}
|
||||
return isRowGripSelected({
|
||||
editor: props.editor,
|
||||
view: props.view,
|
||||
state: props.state,
|
||||
from: props.from
|
||||
});
|
||||
}}
|
||||
class="bg-background flex h-fit w-fit items-center gap-1 rounded border shadow-lg"
|
||||
>
|
||||
<EdraToolTip tooltip="Add Row After">
|
||||
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().addRowAfter().run()}>
|
||||
<ArrowDownFromLine />
|
||||
</Button>
|
||||
</EdraToolTip>
|
||||
<EdraToolTip tooltip="Add Row Before">
|
||||
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().addRowBefore().run()}>
|
||||
<ArrowUpFromLine />
|
||||
</Button>
|
||||
</EdraToolTip>
|
||||
|
||||
<EdraToolTip tooltip="Delete This Row">
|
||||
<Button variant="ghost" size="icon" onclick={() => editor.chain().focus().deleteRow().run()}>
|
||||
<Trash />
|
||||
</Button>
|
||||
</EdraToolTip>
|
||||
</BubbleMenu>
|
||||
@@ -0,0 +1,25 @@
|
||||
:root {
|
||||
--border-color: var(--muted);
|
||||
--border-color-hover: var(--muted-foreground);
|
||||
--blockquote-border: var(--border);
|
||||
--blockquote-color: var(--muted-foreground);
|
||||
--codeblock-bg: var(--muted);
|
||||
--code-bg: var(--muted);
|
||||
--table-border: var(--border);
|
||||
--table-bg-selected: color-mix(in oklch, var(--muted) 60%, transparent);
|
||||
--table-bg-hover: color-mix(in oklch, var(--muted) 30%, transparent);
|
||||
--task-completed-color: var(--muted-foreground);
|
||||
--highlight-color: rgba(0, 255, 0, 0.1);
|
||||
--highlight-border: green;
|
||||
--search-result-bg: yellow;
|
||||
--search-result-current-bg: orange;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--codeblock-bg: color-mix(in oklch, var(--muted) 30%, transparent);
|
||||
}
|
||||
|
||||
.tiptap a {
|
||||
color: oklch(62.3% 0.214 259.815);
|
||||
text-decoration: underline;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { cn } from '$src/tools';
|
||||
import commands from '../commands/toolbar-commands';
|
||||
import type { EdraToolbarProps } from '../types';
|
||||
import Alignment from './components/toolbar/Alignment.svelte';
|
||||
import FontSize from './components/toolbar/FontSize.svelte';
|
||||
import Headings from './components/toolbar/Headings.svelte';
|
||||
import QuickColors from './components/toolbar/QuickColors.svelte';
|
||||
import SearchAndReplace from './components/toolbar/SearchAndReplace.svelte';
|
||||
import ToolBarIcon from './components/ToolBarIcon.svelte';
|
||||
|
||||
const { editor, class: className, excludedCommands, children }: EdraToolbarProps = $props();
|
||||
|
||||
const toolbarCommands = Object.keys(commands).filter((key) => !excludedCommands?.includes(key));
|
||||
</script>
|
||||
|
||||
<div class={cn('edra-toolbar', className)}>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{:else}
|
||||
{#each toolbarCommands as cmd (cmd)}
|
||||
{#if cmd === 'headings'}
|
||||
<Headings {editor} />
|
||||
{:else if cmd === 'alignment'}
|
||||
<Alignment {editor} />
|
||||
{:else}
|
||||
{@const commandGroup = commands[cmd]}
|
||||
{#each commandGroup as command (command)}
|
||||
<ToolBarIcon {editor} {command} />
|
||||
{/each}
|
||||
{/if}
|
||||
{/each}
|
||||
<FontSize {editor} />
|
||||
<QuickColors {editor} />
|
||||
<SearchAndReplace {editor} />
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import { Decoration, DecorationSet, type EditorView } from '@tiptap/pm/view';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
|
||||
/**
|
||||
* Check if the current browser is in mac or not
|
||||
*/
|
||||
export const isMac =
|
||||
typeof window !== 'undefined' && typeof navigator !== 'undefined'
|
||||
? navigator.userAgent.includes('Macintosh') || navigator.userAgent.includes('Mac OS X')
|
||||
: false;
|
||||
|
||||
/**
|
||||
* Function to handle paste event of an image
|
||||
* @param editor Editor - editor instance
|
||||
* @param maxSize number - max size of the image to be pasted in MB, default is 2MB
|
||||
*/
|
||||
export function getHandlePaste(editor: Editor, maxSize: number = 2) {
|
||||
return (view: EditorView, event: ClipboardEvent) => {
|
||||
const item = event.clipboardData?.items[0];
|
||||
|
||||
if (item?.type.indexOf('image') !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const file = item.getAsFile();
|
||||
if (file === null || file.size === undefined) return;
|
||||
const filesize = (file?.size / 1024 / 1024).toFixed(4);
|
||||
|
||||
if (filesize && Number(filesize) > maxSize) {
|
||||
window.alert(`too large image! filesize: ${filesize} mb`);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
// reader.onload = (e) => {
|
||||
// if (e.target?.result) {
|
||||
// editor.commands.setImage({ src: e.target.result as string });
|
||||
// }
|
||||
// };
|
||||
};
|
||||
}
|
||||
|
||||
export const findColors = (doc: Node) => {
|
||||
const hexColor = /(#[0-9a-f]{3,6})\b/gi;
|
||||
const decorations: Decoration[] = [];
|
||||
|
||||
doc.descendants((node, position) => {
|
||||
if (!node.text) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.from(node.text.matchAll(hexColor)).forEach((match) => {
|
||||
const color = match[0];
|
||||
const index = match.index || 0;
|
||||
const from = position + index;
|
||||
const to = from + color.length;
|
||||
const decoration = Decoration.inline(from, to, {
|
||||
class: 'color',
|
||||
style: `--color: ${color}`
|
||||
});
|
||||
|
||||
decorations.push(decoration);
|
||||
});
|
||||
});
|
||||
|
||||
return DecorationSet.create(doc, decorations);
|
||||
};
|
||||
|
||||
/**
|
||||
* Dupilcate content at the current selection
|
||||
* @param editor Editor instance
|
||||
* @param node Node to be duplicated
|
||||
*/
|
||||
export const duplicateContent = (editor: Editor, node: Node) => {
|
||||
const { view } = editor;
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.insertContentAt(selection.to, node.toJSON(), {
|
||||
updateSelection: true
|
||||
})
|
||||
.focus(selection.to)
|
||||
.run();
|
||||
};
|
||||
|
||||
/**
|
||||
* Sets focus on the editor and moves the cursor to the clicked text position,
|
||||
* defaulting to the end of the document if the click is outside any text.
|
||||
*
|
||||
* @param editor - Editor instance
|
||||
* @param event - Optional MouseEvent or KeyboardEvent triggering the focus
|
||||
*/
|
||||
export function focusEditor(editor: Editor | undefined, event?: MouseEvent | KeyboardEvent) {
|
||||
if (!editor) return;
|
||||
// Check if there is a text selection already (i.e. a non-empty selection)
|
||||
const selection = window.getSelection();
|
||||
if (selection && selection.toString().length > 0) {
|
||||
// Focus the editor without modifying selection
|
||||
editor.chain().focus().run();
|
||||
return;
|
||||
}
|
||||
if (event instanceof MouseEvent) {
|
||||
const { clientX, clientY } = event;
|
||||
const pos = editor.view.posAtCoords({ left: clientX, top: clientY })?.pos;
|
||||
if (pos == null) {
|
||||
// If not a valid position, move cursor to the end of the document
|
||||
const endPos = editor.state.doc.content.size;
|
||||
editor.chain().focus().setTextSelection(endPos).run();
|
||||
} else {
|
||||
editor.chain().focus().setTextSelection(pos).run();
|
||||
}
|
||||
} else {
|
||||
editor.chain().focus().run();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.21 0.006 285.885);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.705 0.015 286.067);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.21 0.006 285.885);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.705 0.015 286.067);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.92 0.004 286.32);
|
||||
--primary-foreground: oklch(0.21 0.006 285.885);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.552 0.016 285.938);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.552 0.016 285.938);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import './index.css';
|
||||
|
||||
export * as Button from './base/button';
|
||||
export * as DropdownMenu from './base/dropdown-menu';
|
||||
export * as Input from './base/input';
|
||||
export * as Label from './base/label';
|
||||
export * as Popover from './base/popover';
|
||||
export * as Skeleton from './base/skeleton';
|
||||
export * as Tooltip from './base/tooltip';
|
||||
|
||||
export * from './tools';
|
||||
|
||||
export * from './custom/editor/ui';
|
||||
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user