feat: enhance editor with drag handle and UI improvements
- Add drag handle functionality to editor blocks - Improve dropdown menu component with better state management - Enhance composer with AI-powered features - Update form components to use toast notifications - Add chevron-right and drag-handle SVG icons - Various bug fixes and UI refinements 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
fb01527469
commit
a04d48e549
21 changed files with 851 additions and 64 deletions
|
|
@ -73,6 +73,7 @@ npm run db:backup:sync
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
1. PostgreSQL client tools must be installed (`pg_dump`, `psql`)
|
1. PostgreSQL client tools must be installed (`pg_dump`, `psql`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS
|
# macOS
|
||||||
brew install postgresql
|
brew install postgresql
|
||||||
|
|
@ -82,6 +83,7 @@ npm run db:backup:sync
|
||||||
```
|
```
|
||||||
|
|
||||||
2. Set environment variables in `.env` or `.env.local`:
|
2. Set environment variables in `.env` or `.env.local`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Required for local database operations
|
# Required for local database operations
|
||||||
DATABASE_URL="postgresql://user:password@localhost:5432/dbname"
|
DATABASE_URL="postgresql://user:password@localhost:5432/dbname"
|
||||||
|
|
@ -123,18 +125,23 @@ npm run db:restore ./backups/backup_file.sql.gz remote
|
||||||
### Common Workflows
|
### Common Workflows
|
||||||
|
|
||||||
#### Daily Development
|
#### Daily Development
|
||||||
|
|
||||||
Start your day by syncing the production database to local:
|
Start your day by syncing the production database to local:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run db:backup:sync
|
npm run db:backup:sync
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Before Deploying Schema Changes
|
#### Before Deploying Schema Changes
|
||||||
|
|
||||||
Always backup the remote database:
|
Always backup the remote database:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run db:backup:remote
|
npm run db:backup:remote
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Recover from Mistakes
|
#### Recover from Mistakes
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# See available backups
|
# See available backups
|
||||||
npm run db:backups
|
npm run db:backups
|
||||||
|
|
@ -146,6 +153,7 @@ npm run db:restore ./backups/local_20240615_143022.sql.gz
|
||||||
### Backup Storage
|
### Backup Storage
|
||||||
|
|
||||||
All backups are stored in `./backups/` with timestamps:
|
All backups are stored in `./backups/` with timestamps:
|
||||||
|
|
||||||
- Local: `local_YYYYMMDD_HHMMSS.sql.gz`
|
- Local: `local_YYYYMMDD_HHMMSS.sql.gz`
|
||||||
- Remote: `remote_YYYYMMDD_HHMMSS.sql.gz`
|
- Remote: `remote_YYYYMMDD_HHMMSS.sql.gz`
|
||||||
|
|
||||||
|
|
|
||||||
23
package-lock.json
generated
23
package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aarkue/tiptap-math-extension": "^1.3.6",
|
"@aarkue/tiptap-math-extension": "^1.3.6",
|
||||||
|
"@floating-ui/dom": "^1.7.1",
|
||||||
"@prisma/client": "^6.8.2",
|
"@prisma/client": "^6.8.2",
|
||||||
"@sveltejs/adapter-node": "^5.2.0",
|
"@sveltejs/adapter-node": "^5.2.0",
|
||||||
"@tiptap/core": "^2.12.0",
|
"@tiptap/core": "^2.12.0",
|
||||||
|
|
@ -677,6 +678,28 @@
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@floating-ui/core": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-azI0DrjMMfIug/ExbBaeDVJXcY0a7EPvPjb2xAJPa4HeimBX+Z18HK8QQR3jb6356SnDDdxx+hinMLcJEDdOjw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/utils": "^0.2.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/dom": {
|
||||||
|
"version": "1.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.1.tgz",
|
||||||
|
"integrity": "sha512-cwsmW/zyw5ltYTUeeYJ60CnQuPqmGwuGVhG9w0PRaRKkAyi38BT5CKrpIbb+jtahSwUl04cWzSx9ZOIxeS6RsQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@floating-ui/core": "^1.7.1",
|
||||||
|
"@floating-ui/utils": "^0.2.9"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@floating-ui/utils": {
|
||||||
|
"version": "0.2.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.9.tgz",
|
||||||
|
"integrity": "sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg=="
|
||||||
|
},
|
||||||
"node_modules/@humanwhocodes/module-importer": {
|
"node_modules/@humanwhocodes/module-importer": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,7 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aarkue/tiptap-math-extension": "^1.3.6",
|
"@aarkue/tiptap-math-extension": "^1.3.6",
|
||||||
|
"@floating-ui/dom": "^1.7.1",
|
||||||
"@prisma/client": "^6.8.2",
|
"@prisma/client": "^6.8.2",
|
||||||
"@sveltejs/adapter-node": "^5.2.0",
|
"@sveltejs/adapter-node": "^5.2.0",
|
||||||
"@tiptap/core": "^2.12.0",
|
"@tiptap/core": "^2.12.0",
|
||||||
|
|
|
||||||
3
src/assets/icons/chevron-right.svg
Normal file
3
src/assets/icons/chevron-right.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 12L10 8L6 4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 217 B |
8
src/assets/icons/drag-handle.svg
Normal file
8
src/assets/icons/drag-handle.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<circle cx="5" cy="3" r="1.5" fill="currentColor"/>
|
||||||
|
<circle cx="11" cy="3" r="1.5" fill="currentColor"/>
|
||||||
|
<circle cx="5" cy="8" r="1.5" fill="currentColor"/>
|
||||||
|
<circle cx="11" cy="8" r="1.5" fill="currentColor"/>
|
||||||
|
<circle cx="5" cy="13" r="1.5" fill="currentColor"/>
|
||||||
|
<circle cx="11" cy="13" r="1.5" fill="currentColor"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 431 B |
|
|
@ -464,7 +464,6 @@
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
.form-section {
|
.form-section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
|
|
@ -1,86 +1,207 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { createEventDispatcher, onMount } from 'svelte'
|
import { createEventDispatcher, onMount } from 'svelte'
|
||||||
import { browser } from '$app/environment'
|
import { browser } from '$app/environment'
|
||||||
|
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom'
|
||||||
|
import ChevronRight from '$icons/chevron-right.svg?component'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
isOpen: boolean
|
isOpen: boolean
|
||||||
triggerElement?: HTMLElement
|
triggerElement?: HTMLElement
|
||||||
items: DropdownItem[]
|
items: DropdownItem[]
|
||||||
onClose?: () => void
|
onClose?: () => void
|
||||||
|
isSubmenu?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DropdownItem {
|
interface DropdownItem {
|
||||||
id: string
|
id: string
|
||||||
label: string
|
label?: string
|
||||||
action: () => void
|
action?: () => void
|
||||||
variant?: 'default' | 'danger'
|
variant?: 'default' | 'danger'
|
||||||
divider?: boolean
|
divider?: boolean
|
||||||
|
children?: DropdownItem[]
|
||||||
|
icon?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
let { isOpen = $bindable(), triggerElement, items, onClose }: Props = $props()
|
let { isOpen = $bindable(), triggerElement, items, onClose, isSubmenu = false }: Props = $props()
|
||||||
|
|
||||||
let dropdownElement: HTMLDivElement
|
let dropdownElement: HTMLDivElement
|
||||||
|
let cleanup: (() => void) | null = null
|
||||||
const dispatch = createEventDispatcher()
|
const dispatch = createEventDispatcher()
|
||||||
|
|
||||||
// Calculate position dynamically when needed
|
// Track which submenu is open
|
||||||
const position = $derived(() => {
|
let openSubmenuId = $state<string | null>(null)
|
||||||
if (!isOpen || !triggerElement || !browser) {
|
let submenuElements = $state<Map<string, HTMLElement>>(new Map())
|
||||||
return { top: 0, left: 0 }
|
let submenuCloseTimeout: number | null = null
|
||||||
}
|
|
||||||
|
|
||||||
const rect = triggerElement.getBoundingClientRect()
|
// Position state
|
||||||
const dropdownWidth = 180
|
let x = $state(0)
|
||||||
|
let y = $state(0)
|
||||||
|
|
||||||
|
// Action to set submenu references
|
||||||
|
function submenuRef(node: HTMLElement, params: { item: DropdownItem; submenuElements: Map<string, HTMLElement> }) {
|
||||||
|
if (params.item.children) {
|
||||||
|
params.submenuElements.set(params.item.id, node)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
top: rect.bottom + 4,
|
destroy() {
|
||||||
left: rect.right - dropdownWidth
|
if (params.item.children) {
|
||||||
|
params.submenuElements.delete(params.item.id)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update position using Floating UI
|
||||||
|
async function updatePosition() {
|
||||||
|
if (!triggerElement || !dropdownElement) return
|
||||||
|
|
||||||
|
const { x: newX, y: newY } = await computePosition(triggerElement, dropdownElement, {
|
||||||
|
placement: isSubmenu ? 'right-start' : 'bottom-end',
|
||||||
|
middleware: [
|
||||||
|
offset(isSubmenu ? 0 : 4),
|
||||||
|
flip(),
|
||||||
|
shift({ padding: 8 })
|
||||||
|
]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
x = newX
|
||||||
|
y = newY
|
||||||
|
}
|
||||||
|
|
||||||
function handleItemClick(item: DropdownItem, event: MouseEvent) {
|
function handleItemClick(item: DropdownItem, event: MouseEvent) {
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
|
if (item.action && !item.children) {
|
||||||
item.action()
|
item.action()
|
||||||
isOpen = false
|
isOpen = false
|
||||||
|
openSubmenuId = null // Reset submenu state
|
||||||
onClose?.()
|
onClose?.()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleOutsideClick(event: MouseEvent) {
|
function handleOutsideClick(event: MouseEvent) {
|
||||||
if (!dropdownElement || !isOpen) return
|
if (!dropdownElement || !isOpen) return
|
||||||
|
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
if (!dropdownElement.contains(target) && !triggerElement?.contains(target)) {
|
// Check if click is inside any submenu
|
||||||
|
const clickedInSubmenu = Array.from(submenuElements.values()).some(el => el.contains(target))
|
||||||
|
|
||||||
|
if (!dropdownElement.contains(target) && !triggerElement?.contains(target) && !clickedInSubmenu) {
|
||||||
isOpen = false
|
isOpen = false
|
||||||
|
openSubmenuId = null // Reset submenu state
|
||||||
onClose?.()
|
onClose?.()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleItemMouseEnter(item: DropdownItem) {
|
||||||
|
if (submenuCloseTimeout) {
|
||||||
|
clearTimeout(submenuCloseTimeout)
|
||||||
|
submenuCloseTimeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.children) {
|
||||||
|
openSubmenuId = item.id
|
||||||
|
} else {
|
||||||
|
openSubmenuId = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleItemMouseLeave(item: DropdownItem) {
|
||||||
|
if (item.children) {
|
||||||
|
// Add delay before closing submenu
|
||||||
|
submenuCloseTimeout = window.setTimeout(() => {
|
||||||
|
if (openSubmenuId === item.id) {
|
||||||
|
openSubmenuId = null
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmenuMouseEnter() {
|
||||||
|
if (submenuCloseTimeout) {
|
||||||
|
clearTimeout(submenuCloseTimeout)
|
||||||
|
submenuCloseTimeout = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmenuMouseLeave(itemId: string) {
|
||||||
|
submenuCloseTimeout = window.setTimeout(() => {
|
||||||
|
if (openSubmenuId === itemId) {
|
||||||
|
openSubmenuId = null
|
||||||
|
}
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up auto-update for position when dropdown is open
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (browser && isOpen) {
|
if (browser && isOpen && triggerElement && dropdownElement) {
|
||||||
|
// Initial position update
|
||||||
|
updatePosition()
|
||||||
|
|
||||||
|
// Set up auto-update
|
||||||
|
cleanup = autoUpdate(triggerElement, dropdownElement, updatePosition)
|
||||||
|
|
||||||
|
// Add outside click listener
|
||||||
document.addEventListener('click', handleOutsideClick)
|
document.addEventListener('click', handleOutsideClick)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
cleanup?.()
|
||||||
|
cleanup = null
|
||||||
document.removeEventListener('click', handleOutsideClick)
|
document.removeEventListener('click', handleOutsideClick)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Reset submenu state when dropdown closes
|
||||||
|
$effect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
openSubmenuId = null
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if isOpen && browser}
|
{#if isOpen && browser}
|
||||||
<div
|
<div
|
||||||
bind:this={dropdownElement}
|
bind:this={dropdownElement}
|
||||||
class="dropdown-menu"
|
class="dropdown-menu"
|
||||||
style="top: {position().top}px; left: {position().left}px"
|
class:submenu={isSubmenu}
|
||||||
|
style="position: fixed; left: {x}px; top: {y}px"
|
||||||
>
|
>
|
||||||
{#each items as item}
|
{#each items as item}
|
||||||
{#if item.divider}
|
{#if item.divider}
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
{:else}
|
{:else}
|
||||||
<button
|
<button
|
||||||
|
use:submenuRef={{ item, submenuElements }}
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
class:danger={item.variant === 'danger'}
|
class:danger={item.variant === 'danger'}
|
||||||
|
class:has-children={item.children}
|
||||||
onclick={(e) => handleItemClick(item, e)}
|
onclick={(e) => handleItemClick(item, e)}
|
||||||
|
onmouseenter={() => handleItemMouseEnter(item)}
|
||||||
|
onmouseleave={() => handleItemMouseLeave(item)}
|
||||||
>
|
>
|
||||||
{item.label}
|
<span class="item-label">{item.label}</span>
|
||||||
|
{#if item.children}
|
||||||
|
<span class="submenu-icon">
|
||||||
|
<ChevronRight />
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{#if item.children && openSubmenuId === item.id}
|
||||||
|
<div
|
||||||
|
onmouseenter={handleSubmenuMouseEnter}
|
||||||
|
onmouseleave={() => handleSubmenuMouseLeave(item.id)}
|
||||||
|
>
|
||||||
|
<svelte:self
|
||||||
|
isOpen={true}
|
||||||
|
triggerElement={submenuElements.get(item.id)}
|
||||||
|
items={item.children}
|
||||||
|
onClose={onClose}
|
||||||
|
isSubmenu={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -90,7 +211,6 @@
|
||||||
@import '$styles/variables.scss';
|
@import '$styles/variables.scss';
|
||||||
|
|
||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
position: fixed;
|
|
||||||
background: white;
|
background: white;
|
||||||
border: 1px solid $grey-85;
|
border: 1px solid $grey-85;
|
||||||
border-radius: $unit;
|
border-radius: $unit;
|
||||||
|
|
@ -98,6 +218,8 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
min-width: 180px;
|
min-width: 180px;
|
||||||
z-index: 1050;
|
z-index: 1050;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
|
|
@ -110,7 +232,9 @@
|
||||||
color: $grey-20;
|
color: $grey-20;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
display: block;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $grey-95;
|
background-color: $grey-95;
|
||||||
|
|
@ -119,6 +243,38 @@
|
||||||
&.danger {
|
&.danger {
|
||||||
color: $red-60;
|
color: $red-60;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.has-children {
|
||||||
|
padding-right: $unit-2x;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-label {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submenu-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-left: $unit;
|
||||||
|
color: $grey-40;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
:global(svg) {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(path) {
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-divider {
|
.dropdown-divider {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
import LinkContextMenuComponent from '$lib/components/edra/headless/components/LinkContextMenu.svelte'
|
import LinkContextMenuComponent from '$lib/components/edra/headless/components/LinkContextMenu.svelte'
|
||||||
import LinkEditDialog from '$lib/components/edra/headless/components/LinkEditDialog.svelte'
|
import LinkEditDialog from '$lib/components/edra/headless/components/LinkEditDialog.svelte'
|
||||||
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
|
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
|
||||||
|
import DragHandle from '$lib/components/edra/drag-handle.svelte'
|
||||||
import { mediaSelectionStore } from '$lib/stores/media-selection'
|
import { mediaSelectionStore } from '$lib/stores/media-selection'
|
||||||
import type { Media } from '@prisma/client'
|
import type { Media } from '@prisma/client'
|
||||||
|
|
||||||
|
|
@ -629,7 +630,6 @@
|
||||||
return () => editor?.destroy()
|
return () => editor?.destroy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
export function save(): JSONContent | null {
|
export function save(): JSONContent | null {
|
||||||
return editor?.getJSON() || null
|
return editor?.getJSON() || null
|
||||||
|
|
@ -789,6 +789,10 @@
|
||||||
class:with-toolbar={showToolbar}
|
class:with-toolbar={showToolbar}
|
||||||
style={`min-height: ${minHeight}px`}
|
style={`min-height: ${minHeight}px`}
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
|
{#if editor}
|
||||||
|
<DragHandle {editor} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Media Dropdown Portal -->
|
<!-- Media Dropdown Portal -->
|
||||||
|
|
@ -1108,6 +1112,91 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Block spacing for visual separation in composer */
|
||||||
|
:global(.ProseMirror p) {
|
||||||
|
margin-top: $unit;
|
||||||
|
margin-bottom: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ProseMirror h1),
|
||||||
|
:global(.ProseMirror h2),
|
||||||
|
:global(.ProseMirror h3),
|
||||||
|
:global(.ProseMirror h4),
|
||||||
|
:global(.ProseMirror h5),
|
||||||
|
:global(.ProseMirror h6) {
|
||||||
|
padding-top: $unit;
|
||||||
|
padding-bottom: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ProseMirror ul),
|
||||||
|
:global(.ProseMirror ol) {
|
||||||
|
padding-top: $unit;
|
||||||
|
padding-bottom: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ProseMirror blockquote) {
|
||||||
|
margin-top: $unit;
|
||||||
|
margin-bottom: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ProseMirror pre),
|
||||||
|
:global(.ProseMirror .code-wrapper) {
|
||||||
|
margin-top: $unit;
|
||||||
|
margin-bottom: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ProseMirror hr) {
|
||||||
|
margin-top: $unit;
|
||||||
|
margin-bottom: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ProseMirror .tableWrapper) {
|
||||||
|
margin-top: $unit;
|
||||||
|
margin-bottom: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ProseMirror img),
|
||||||
|
:global(.ProseMirror video),
|
||||||
|
:global(.ProseMirror audio),
|
||||||
|
:global(.ProseMirror iframe) {
|
||||||
|
margin-top: $unit;
|
||||||
|
margin-bottom: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ProseMirror .image-placeholder),
|
||||||
|
:global(.ProseMirror .video-placeholder),
|
||||||
|
:global(.ProseMirror .audio-placeholder),
|
||||||
|
:global(.ProseMirror .gallery-placeholder),
|
||||||
|
:global(.ProseMirror .url-embed-placeholder),
|
||||||
|
:global(.ProseMirror .geolocation-placeholder) {
|
||||||
|
margin-top: $unit;
|
||||||
|
margin-bottom: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ProseMirror .node-urlEmbed) {
|
||||||
|
padding-top: $unit;
|
||||||
|
padding-bottom: $unit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Link styling */
|
||||||
|
:global(.ProseMirror a) {
|
||||||
|
color: $accent-color;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ProseMirror a span) {
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ProseMirror a:hover) {
|
||||||
|
color: $red-40;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.ProseMirror a:hover span) {
|
||||||
|
color: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Text Style Dropdown Styles */
|
/* Text Style Dropdown Styles */
|
||||||
.text-style-dropdown {
|
.text-style-dropdown {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
@ -1226,4 +1315,33 @@
|
||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Drag handle styles */
|
||||||
|
:global(.drag-handle) {
|
||||||
|
position: fixed;
|
||||||
|
width: 20px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: grab;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
color: $grey-40;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.drag-handle.hide) {
|
||||||
|
opacity: 0 !important;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.drag-handle:not(.hide)) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
:global(.drag-handle:active) {
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -546,9 +546,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="footer-right">
|
<div class="footer-right">
|
||||||
<Button variant="primary" onclick={handleSave} disabled={isSaving}>
|
<Button variant="primary" onclick={handleSave} disabled={isSaving}>Save Changes</Button>
|
||||||
Save Changes
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,9 @@
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadingToastId = toast.loading(`${status === 'published' ? 'Publishing' : 'Saving'} photo post...`)
|
const loadingToastId = toast.loading(
|
||||||
|
`${status === 'published' ? 'Publishing' : 'Saving'} photo post...`
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isSaving = true
|
isSaving = true
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,9 @@
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadingToastId = toast.loading(`${publishStatus === 'published' ? 'Publishing' : 'Saving'} post...`)
|
const loadingToastId = toast.loading(
|
||||||
|
`${publishStatus === 'published' ? 'Publishing' : 'Saving'} post...`
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isSaving = true
|
isSaving = true
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,455 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Editor } from '@tiptap/core'
|
import type { Editor } from '@tiptap/core'
|
||||||
|
import type { Node } from '@tiptap/pm/model'
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import GripVertical from 'lucide-svelte/icons/grip-vertical'
|
|
||||||
import { DragHandlePlugin } from './extensions/drag-handle/index.js'
|
import { DragHandlePlugin } from './extensions/drag-handle/index.js'
|
||||||
|
import DropdownMenu from '../admin/DropdownMenu.svelte'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
editor: Editor
|
editor: Editor
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DropdownItem {
|
||||||
|
id: string
|
||||||
|
label?: string
|
||||||
|
action?: () => void
|
||||||
|
variant?: 'default' | 'danger'
|
||||||
|
divider?: boolean
|
||||||
|
children?: DropdownItem[]
|
||||||
|
icon?: string
|
||||||
|
}
|
||||||
|
|
||||||
const { editor }: Props = $props()
|
const { editor }: Props = $props()
|
||||||
|
|
||||||
const pluginKey = 'globalDragHandle'
|
const pluginKey = 'globalDragHandle'
|
||||||
|
|
||||||
|
// State
|
||||||
|
let isMenuOpen = $state(false)
|
||||||
|
let currentNode = $state<{ node: Node; pos: number } | null>(null)
|
||||||
|
let dragHandleContainer = $state<HTMLElement>()
|
||||||
|
|
||||||
|
// Generate menu items based on current node
|
||||||
|
const menuItems = $derived(() => {
|
||||||
|
if (!currentNode) return []
|
||||||
|
|
||||||
|
const items: DropdownItem[] = []
|
||||||
|
const nodeType = currentNode.node.type.name
|
||||||
|
|
||||||
|
// Block type conversion options
|
||||||
|
if (nodeType === 'paragraph' || nodeType === 'heading') {
|
||||||
|
const turnIntoChildren = []
|
||||||
|
|
||||||
|
turnIntoChildren.push({
|
||||||
|
id: 'convert-paragraph',
|
||||||
|
label: 'Paragraph',
|
||||||
|
action: () => convertBlockType('paragraph')
|
||||||
|
})
|
||||||
|
turnIntoChildren.push({
|
||||||
|
id: 'convert-h1',
|
||||||
|
label: 'Heading 1',
|
||||||
|
action: () => convertBlockType('heading', { level: 1 })
|
||||||
|
})
|
||||||
|
turnIntoChildren.push({
|
||||||
|
id: 'convert-h2',
|
||||||
|
label: 'Heading 2',
|
||||||
|
action: () => convertBlockType('heading', { level: 2 })
|
||||||
|
})
|
||||||
|
turnIntoChildren.push({
|
||||||
|
id: 'convert-h3',
|
||||||
|
label: 'Heading 3',
|
||||||
|
action: () => convertBlockType('heading', { level: 3 })
|
||||||
|
})
|
||||||
|
turnIntoChildren.push({
|
||||||
|
id: 'convert-blockquote',
|
||||||
|
label: 'Quote',
|
||||||
|
action: () => convertBlockType('blockquote')
|
||||||
|
})
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: 'turn-into',
|
||||||
|
label: 'Turn into',
|
||||||
|
children: turnIntoChildren
|
||||||
|
})
|
||||||
|
items.push({
|
||||||
|
id: 'divider-1',
|
||||||
|
divider: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// List-specific actions
|
||||||
|
if (nodeType === 'listItem') {
|
||||||
|
const turnIntoChildren = []
|
||||||
|
|
||||||
|
turnIntoChildren.push({
|
||||||
|
id: 'convert-bullet',
|
||||||
|
label: 'Bullet List',
|
||||||
|
action: () => convertToList('bulletList')
|
||||||
|
})
|
||||||
|
turnIntoChildren.push({
|
||||||
|
id: 'convert-numbered',
|
||||||
|
label: 'Numbered List',
|
||||||
|
action: () => convertToList('orderedList')
|
||||||
|
})
|
||||||
|
turnIntoChildren.push({
|
||||||
|
id: 'convert-task',
|
||||||
|
label: 'Task List',
|
||||||
|
action: () => convertToList('taskList')
|
||||||
|
})
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: 'turn-into',
|
||||||
|
label: 'Turn into',
|
||||||
|
children: turnIntoChildren
|
||||||
|
})
|
||||||
|
items.push({
|
||||||
|
id: 'divider-1',
|
||||||
|
divider: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL embed specific actions
|
||||||
|
if (nodeType === 'urlEmbed') {
|
||||||
|
items.push({
|
||||||
|
id: 'convert-to-link',
|
||||||
|
label: 'Convert to Link',
|
||||||
|
action: () => convertEmbedToLink()
|
||||||
|
})
|
||||||
|
items.push({
|
||||||
|
id: 'divider-1',
|
||||||
|
divider: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if block contains links that could have cards added
|
||||||
|
if ((nodeType === 'paragraph' || nodeType === 'heading') && hasLinks(currentNode.node)) {
|
||||||
|
items.push({
|
||||||
|
id: 'add-link-cards',
|
||||||
|
label: 'Add cards for links',
|
||||||
|
action: () => addCardsForLinks()
|
||||||
|
})
|
||||||
|
items.push({
|
||||||
|
id: 'divider-1',
|
||||||
|
divider: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common actions
|
||||||
|
items.push({
|
||||||
|
id: 'duplicate',
|
||||||
|
label: 'Duplicate',
|
||||||
|
action: () => duplicateBlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: 'copy',
|
||||||
|
label: 'Copy',
|
||||||
|
action: () => copyBlock()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Text formatting removal
|
||||||
|
if (nodeType === 'paragraph' || nodeType === 'heading') {
|
||||||
|
items.push({
|
||||||
|
id: 'remove-formatting',
|
||||||
|
label: 'Remove Formatting',
|
||||||
|
action: () => removeFormatting()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: 'divider-2',
|
||||||
|
divider: true
|
||||||
|
})
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
id: 'delete',
|
||||||
|
label: 'Delete',
|
||||||
|
action: () => deleteBlock(),
|
||||||
|
variant: 'danger' as const
|
||||||
|
})
|
||||||
|
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper functions
|
||||||
|
function hasLinks(node: Node): boolean {
|
||||||
|
let hasLink = false
|
||||||
|
node.descendants((child) => {
|
||||||
|
if (child.type.name === 'link' || (child.isText && child.marks.some(mark => mark.type.name === 'link'))) {
|
||||||
|
hasLink = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return hasLink
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block manipulation functions
|
||||||
|
function convertBlockType(type: string, attrs?: any) {
|
||||||
|
console.log('convertBlockType called:', type, attrs)
|
||||||
|
if (!currentNode) {
|
||||||
|
console.log('No current node')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pos } = currentNode
|
||||||
|
console.log('Current node:', currentNode.node.type.name, 'at pos:', pos)
|
||||||
|
|
||||||
|
// Focus the editor first
|
||||||
|
editor.commands.focus()
|
||||||
|
|
||||||
|
// Convert the block type using chain commands
|
||||||
|
if (type === 'paragraph') {
|
||||||
|
editor.chain().focus().setParagraph().run()
|
||||||
|
} else if (type === 'heading' && attrs?.level) {
|
||||||
|
editor.chain().focus().setHeading({ level: attrs.level }).run()
|
||||||
|
} else if (type === 'blockquote') {
|
||||||
|
editor.chain().focus().setBlockquote().run()
|
||||||
|
}
|
||||||
|
|
||||||
|
isMenuOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertToList(listType: string) {
|
||||||
|
if (!currentNode) return
|
||||||
|
|
||||||
|
const { pos } = currentNode
|
||||||
|
const resolvedPos = editor.state.doc.resolve(pos)
|
||||||
|
|
||||||
|
// Get the position of the list item
|
||||||
|
let nodePos = pos
|
||||||
|
if (resolvedPos.parent.type.name === 'listItem') {
|
||||||
|
nodePos = resolvedPos.before(resolvedPos.depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set selection to the list item
|
||||||
|
editor.commands.setNodeSelection(nodePos)
|
||||||
|
|
||||||
|
// Convert to the appropriate list type
|
||||||
|
if (listType === 'bulletList') {
|
||||||
|
editor.commands.toggleBulletList()
|
||||||
|
} else if (listType === 'orderedList') {
|
||||||
|
editor.commands.toggleOrderedList()
|
||||||
|
} else if (listType === 'taskList') {
|
||||||
|
editor.commands.toggleTaskList()
|
||||||
|
}
|
||||||
|
|
||||||
|
isMenuOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertEmbedToLink() {
|
||||||
|
if (!currentNode) return
|
||||||
|
|
||||||
|
const { node, pos } = currentNode
|
||||||
|
const url = node.attrs.url
|
||||||
|
const title = node.attrs.title || url
|
||||||
|
|
||||||
|
// Get the actual position of the urlEmbed node
|
||||||
|
const nodePos = pos
|
||||||
|
const nodeSize = node.nodeSize
|
||||||
|
|
||||||
|
// Replace embed with a paragraph containing a link
|
||||||
|
editor.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange({ from: nodePos, to: nodePos + nodeSize })
|
||||||
|
.insertContentAt(nodePos, {
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [{
|
||||||
|
type: 'text',
|
||||||
|
text: title,
|
||||||
|
marks: [{
|
||||||
|
type: 'link',
|
||||||
|
attrs: {
|
||||||
|
href: url,
|
||||||
|
target: '_blank'
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
.run()
|
||||||
|
|
||||||
|
isMenuOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCardsForLinks() {
|
||||||
|
if (!currentNode) return
|
||||||
|
|
||||||
|
const { node, pos } = currentNode
|
||||||
|
const links: { url: string; text: string }[] = []
|
||||||
|
|
||||||
|
// Collect all links in the current block
|
||||||
|
node.descendants((child) => {
|
||||||
|
if (child.isText && child.marks.some(mark => mark.type.name === 'link')) {
|
||||||
|
const linkMark = child.marks.find(mark => mark.type.name === 'link')
|
||||||
|
if (linkMark && linkMark.attrs.href) {
|
||||||
|
links.push({
|
||||||
|
url: linkMark.attrs.href,
|
||||||
|
text: child.text || linkMark.attrs.href
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Insert embeds after the current block
|
||||||
|
if (links.length > 0) {
|
||||||
|
const nodeEnd = pos + node.nodeSize
|
||||||
|
const embeds = links.map(link => ({
|
||||||
|
type: 'urlEmbed',
|
||||||
|
attrs: {
|
||||||
|
url: link.url,
|
||||||
|
title: link.text
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
editor.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContentAt(nodeEnd, embeds)
|
||||||
|
.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
isMenuOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFormatting() {
|
||||||
|
if (!currentNode) return
|
||||||
|
|
||||||
|
const { pos } = currentNode
|
||||||
|
const resolvedPos = editor.state.doc.resolve(pos)
|
||||||
|
const nodeStart = resolvedPos.before(resolvedPos.depth)
|
||||||
|
const nodeEnd = nodeStart + resolvedPos.node(resolvedPos.depth).nodeSize
|
||||||
|
|
||||||
|
editor.chain()
|
||||||
|
.focus()
|
||||||
|
.setTextSelection({ from: nodeStart, to: nodeEnd })
|
||||||
|
.clearNodes()
|
||||||
|
.unsetAllMarks()
|
||||||
|
.run()
|
||||||
|
|
||||||
|
isMenuOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicateBlock() {
|
||||||
|
if (!currentNode) return
|
||||||
|
|
||||||
|
const { node, pos } = currentNode
|
||||||
|
const resolvedPos = editor.state.doc.resolve(pos)
|
||||||
|
const nodeEnd = resolvedPos.after(resolvedPos.depth)
|
||||||
|
|
||||||
|
editor.chain()
|
||||||
|
.focus()
|
||||||
|
.insertContentAt(nodeEnd, node.toJSON())
|
||||||
|
.run()
|
||||||
|
|
||||||
|
isMenuOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyBlock() {
|
||||||
|
if (!currentNode) return
|
||||||
|
|
||||||
|
const { pos } = currentNode
|
||||||
|
const resolvedPos = editor.state.doc.resolve(pos)
|
||||||
|
const nodeStart = resolvedPos.before(resolvedPos.depth)
|
||||||
|
const nodeEnd = nodeStart + resolvedPos.node(resolvedPos.depth).nodeSize
|
||||||
|
|
||||||
|
editor.chain()
|
||||||
|
.focus()
|
||||||
|
.setTextSelection({ from: nodeStart, to: nodeEnd })
|
||||||
|
.run()
|
||||||
|
|
||||||
|
document.execCommand('copy')
|
||||||
|
|
||||||
|
// Clear selection after copy
|
||||||
|
editor.chain()
|
||||||
|
.focus()
|
||||||
|
.setTextSelection(nodeEnd)
|
||||||
|
.run()
|
||||||
|
|
||||||
|
isMenuOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteBlock() {
|
||||||
|
if (!currentNode) return
|
||||||
|
|
||||||
|
const { pos } = currentNode
|
||||||
|
const resolvedPos = editor.state.doc.resolve(pos)
|
||||||
|
const nodeStart = resolvedPos.before(resolvedPos.depth)
|
||||||
|
const nodeEnd = nodeStart + resolvedPos.node(resolvedPos.depth).nodeSize
|
||||||
|
|
||||||
|
editor.chain()
|
||||||
|
.focus()
|
||||||
|
.deleteRange({ from: nodeStart, to: nodeEnd })
|
||||||
|
.run()
|
||||||
|
|
||||||
|
isMenuOpen = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMenuClick(e: MouseEvent) {
|
||||||
|
console.log('Drag handle clicked!', e)
|
||||||
|
console.log('Current node:', currentNode)
|
||||||
|
console.log('Menu items:', menuItems())
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
// Only toggle if we're clicking on the same node
|
||||||
|
// If clicking on a different node while menu is open, just update the menu
|
||||||
|
isMenuOpen = !isMenuOpen
|
||||||
|
console.log('Menu open state:', isMenuOpen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent drag handle from hiding when menu is open
|
||||||
|
$effect(() => {
|
||||||
|
if (dragHandleContainer && isMenuOpen) {
|
||||||
|
dragHandleContainer.classList.add('menu-open')
|
||||||
|
} else if (dragHandleContainer) {
|
||||||
|
dragHandleContainer.classList.remove('menu-open')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
// Register the plugin with onMouseMove callback
|
||||||
const plugin = DragHandlePlugin({
|
const plugin = DragHandlePlugin({
|
||||||
pluginKey: pluginKey,
|
pluginKey: pluginKey,
|
||||||
dragHandleWidth: 20,
|
dragHandleWidth: 24,
|
||||||
scrollTreshold: 100,
|
scrollTreshold: 100,
|
||||||
dragHandleSelector: '.drag-handle',
|
|
||||||
excludedTags: ['pre', 'code', 'table p'],
|
excludedTags: ['pre', 'code', 'table p'],
|
||||||
customNodes: []
|
customNodes: ['urlEmbed', 'image', 'video', 'audio', 'gallery', 'iframe', 'geolocation'],
|
||||||
|
onMouseMove: (data) => {
|
||||||
|
console.log('Mouse move over node:', data)
|
||||||
|
currentNode = data
|
||||||
|
}
|
||||||
})
|
})
|
||||||
editor.registerPlugin(plugin)
|
editor.registerPlugin(plugin)
|
||||||
return () => editor.unregisterPlugin(pluginKey)
|
|
||||||
|
// Find the existing drag handle created by the plugin and add click listener
|
||||||
|
const checkForDragHandle = setInterval(() => {
|
||||||
|
const existingDragHandle = document.querySelector('.drag-handle')
|
||||||
|
if (existingDragHandle && !(existingDragHandle as any).__menuListener) {
|
||||||
|
console.log('Found drag handle, adding click listener')
|
||||||
|
existingDragHandle.addEventListener('click', handleMenuClick)
|
||||||
|
;(existingDragHandle as any).__menuListener = true
|
||||||
|
|
||||||
|
// Update our reference to use the existing drag handle
|
||||||
|
dragHandleContainer = existingDragHandle as HTMLElement
|
||||||
|
clearInterval(checkForDragHandle)
|
||||||
|
}
|
||||||
|
}, 100)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editor.unregisterPlugin(pluginKey)
|
||||||
|
clearInterval(checkForDragHandle)
|
||||||
|
const existingDragHandle = document.querySelector('.drag-handle')
|
||||||
|
if (existingDragHandle) {
|
||||||
|
existingDragHandle.removeEventListener('click', handleMenuClick)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="drag-handle">
|
{#if dragHandleContainer}
|
||||||
<GripVertical />
|
<DropdownMenu
|
||||||
</div>
|
bind:isOpen={isMenuOpen}
|
||||||
|
triggerElement={dragHandleContainer}
|
||||||
|
items={menuItems()}
|
||||||
|
onClose={() => {
|
||||||
|
console.log('Dropdown closed')
|
||||||
|
isMenuOpen = false
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,6 @@ export const initiateEditor = (
|
||||||
limit
|
limit
|
||||||
}),
|
}),
|
||||||
SearchAndReplace,
|
SearchAndReplace,
|
||||||
|
|
||||||
...(extensions ?? [])
|
...(extensions ?? [])
|
||||||
],
|
],
|
||||||
autofocus: true,
|
autofocus: true,
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/stat
|
||||||
import { Fragment, Slice, Node } from '@tiptap/pm/model'
|
import { Fragment, Slice, Node } from '@tiptap/pm/model'
|
||||||
import { EditorView } from '@tiptap/pm/view'
|
import { EditorView } from '@tiptap/pm/view'
|
||||||
import { serializeForClipboard } from './ClipboardSerializer.js'
|
import { serializeForClipboard } from './ClipboardSerializer.js'
|
||||||
|
import DragHandleIcon from '$icons/drag-handle.svg?raw'
|
||||||
|
|
||||||
export interface GlobalDragHandleOptions {
|
export interface GlobalDragHandleOptions {
|
||||||
/**
|
/**
|
||||||
|
|
@ -70,6 +71,9 @@ function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHa
|
||||||
'h4',
|
'h4',
|
||||||
'h5',
|
'h5',
|
||||||
'h6',
|
'h6',
|
||||||
|
'[data-drag-handle]', // NodeView components with drag handle
|
||||||
|
'.edra-url-embed-wrapper', // URL embed wrapper
|
||||||
|
'.edra-youtube-embed-card', // YouTube embed
|
||||||
...options.customNodes.map((node) => `[data-type=${node}]`)
|
...options.customNodes.map((node) => `[data-type=${node}]`)
|
||||||
].join(', ')
|
].join(', ')
|
||||||
return document
|
return document
|
||||||
|
|
@ -192,7 +196,11 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
||||||
const relatedTarget = event.relatedTarget as HTMLElement
|
const relatedTarget = event.relatedTarget as HTMLElement
|
||||||
const isInsideEditor =
|
const isInsideEditor =
|
||||||
relatedTarget?.classList.contains('tiptap') ||
|
relatedTarget?.classList.contains('tiptap') ||
|
||||||
relatedTarget?.classList.contains('drag-handle')
|
relatedTarget?.classList.contains('drag-handle') ||
|
||||||
|
relatedTarget?.classList.contains('drag-handle-menu') ||
|
||||||
|
relatedTarget?.classList.contains('dropdown-menu') ||
|
||||||
|
relatedTarget?.closest('.drag-handle') ||
|
||||||
|
relatedTarget?.closest('.dropdown-menu')
|
||||||
|
|
||||||
if (isInsideEditor) return
|
if (isInsideEditor) return
|
||||||
}
|
}
|
||||||
|
|
@ -210,6 +218,11 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
||||||
dragHandleElement.dataset.dragHandle = ''
|
dragHandleElement.dataset.dragHandle = ''
|
||||||
dragHandleElement.classList.add('drag-handle')
|
dragHandleElement.classList.add('drag-handle')
|
||||||
|
|
||||||
|
// Add custom drag handle SVG if element was created (not selected)
|
||||||
|
if (!handleBySelector) {
|
||||||
|
dragHandleElement.innerHTML = DragHandleIcon
|
||||||
|
}
|
||||||
|
|
||||||
function onDragHandleDragStart(e: DragEvent) {
|
function onDragHandleDragStart(e: DragEvent) {
|
||||||
handleDragStart(e, view)
|
handleDragStart(e, view)
|
||||||
}
|
}
|
||||||
|
|
@ -254,6 +267,18 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if we're hovering over the drag handle itself
|
||||||
|
const target = event.target as HTMLElement
|
||||||
|
if (target.closest('.drag-handle') || target.closest('.dropdown-menu')) {
|
||||||
|
// Keep the handle visible when hovering over it or the dropdown
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't move the drag handle if the menu is open
|
||||||
|
if (dragHandleElement?.classList.contains('menu-open')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const node = nodeDOMAtCoords(
|
const node = nodeDOMAtCoords(
|
||||||
{
|
{
|
||||||
x: event.clientX + 50 + options.dragHandleWidth,
|
x: event.clientX + 50 + options.dragHandleWidth,
|
||||||
|
|
@ -274,21 +299,34 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
||||||
if (nodePos !== undefined) {
|
if (nodePos !== undefined) {
|
||||||
const currentNode = view.state.doc.nodeAt(nodePos)
|
const currentNode = view.state.doc.nodeAt(nodePos)
|
||||||
if (currentNode !== null) {
|
if (currentNode !== null) {
|
||||||
|
// Still update the current node for tracking, but don't reposition if menu is open
|
||||||
options.onMouseMove?.({ node: currentNode, pos: nodePos })
|
options.onMouseMove?.({ node: currentNode, pos: nodePos })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Don't reposition the drag handle if menu is open
|
||||||
|
if (dragHandleElement?.classList.contains('menu-open')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const compStyle = window.getComputedStyle(node)
|
const compStyle = window.getComputedStyle(node)
|
||||||
|
const paddingTop = parseInt(compStyle.paddingTop, 10)
|
||||||
|
const rect = absoluteRect(node)
|
||||||
|
|
||||||
|
// For custom nodes like embeds, position at the top of the element
|
||||||
|
const isCustomNode = node.matches('[data-drag-handle], .edra-url-embed-wrapper, .edra-youtube-embed-card, [data-type]')
|
||||||
|
if (isCustomNode) {
|
||||||
|
// For NodeView components, position handle at top with small offset
|
||||||
|
rect.top += 8
|
||||||
|
} else {
|
||||||
|
// For text nodes, calculate based on line height
|
||||||
const parsedLineHeight = parseInt(compStyle.lineHeight, 10)
|
const parsedLineHeight = parseInt(compStyle.lineHeight, 10)
|
||||||
const lineHeight = isNaN(parsedLineHeight)
|
const lineHeight = isNaN(parsedLineHeight)
|
||||||
? parseInt(compStyle.fontSize) * 1.2
|
? parseInt(compStyle.fontSize) * 1.2
|
||||||
: parsedLineHeight
|
: parsedLineHeight
|
||||||
const paddingTop = parseInt(compStyle.paddingTop, 10)
|
|
||||||
|
|
||||||
const rect = absoluteRect(node)
|
|
||||||
|
|
||||||
rect.top += (lineHeight - 24) / 2
|
rect.top += (lineHeight - 24) / 2
|
||||||
rect.top += paddingTop
|
rect.top += paddingTop
|
||||||
|
}
|
||||||
// Li markers
|
// Li markers
|
||||||
if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
|
if (node.matches('ul:not([data-type=taskList]) li, ol li')) {
|
||||||
rect.left -= options.dragHandleWidth
|
rect.left -= options.dragHandleWidth
|
||||||
|
|
@ -297,8 +335,9 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
||||||
|
|
||||||
if (!dragHandleElement) return
|
if (!dragHandleElement) return
|
||||||
|
|
||||||
dragHandleElement.style.left = `${rect.left - rect.width}px`
|
// Add 8px gap between drag handle and content
|
||||||
dragHandleElement.style.top = `${rect.top}px`
|
dragHandleElement.style.left = `${rect.left - rect.width - 8}px`
|
||||||
|
dragHandleElement.style.top = `${rect.top - 4}px` // Offset for padding
|
||||||
showDragHandle()
|
showDragHandle()
|
||||||
},
|
},
|
||||||
keydown: () => {
|
keydown: () => {
|
||||||
|
|
|
||||||
|
|
@ -27,8 +27,8 @@ export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOp
|
||||||
},
|
},
|
||||||
mediaId: {
|
mediaId: {
|
||||||
default: null,
|
default: null,
|
||||||
parseHTML: element => element.getAttribute('data-media-id'),
|
parseHTML: (element) => element.getAttribute('data-media-id'),
|
||||||
renderHTML: attributes => {
|
renderHTML: (attributes) => {
|
||||||
if (!attributes.mediaId) {
|
if (!attributes.mediaId) {
|
||||||
return {}
|
return {}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,6 @@
|
||||||
// Set a reasonable default width (max 600px)
|
// Set a reasonable default width (max 600px)
|
||||||
const displayWidth = media.width && media.width > 600 ? 600 : media.width
|
const displayWidth = media.width && media.width > 600 ? 600 : media.width
|
||||||
|
|
||||||
|
|
||||||
const imageAttrs = {
|
const imageAttrs = {
|
||||||
src: media.url,
|
src: media.url,
|
||||||
alt: media.altText || '',
|
alt: media.altText || '',
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,13 @@ import { toast as sonnerToast } from 'svelte-sonner'
|
||||||
|
|
||||||
export interface ToastOptions {
|
export interface ToastOptions {
|
||||||
duration?: number
|
duration?: number
|
||||||
position?: 'top-left' | 'top-center' | 'top-right' | 'bottom-left' | 'bottom-center' | 'bottom-right'
|
position?:
|
||||||
|
| 'top-left'
|
||||||
|
| 'top-center'
|
||||||
|
| 'top-right'
|
||||||
|
| 'bottom-left'
|
||||||
|
| 'bottom-center'
|
||||||
|
| 'bottom-right'
|
||||||
description?: string
|
description?: string
|
||||||
action?: {
|
action?: {
|
||||||
label: string
|
label: string
|
||||||
|
|
|
||||||
|
|
@ -170,7 +170,9 @@
|
||||||
<span class="meta-item">📍 {album.location}</span>
|
<span class="meta-item">📍 {album.location}</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class="meta-item"
|
<span class="meta-item"
|
||||||
>📷 {album.photos?.length || 0} photo{(album.photos?.length || 0) !== 1 ? 's' : ''}</span
|
>📷 {album.photos?.length || 0} photo{(album.photos?.length || 0) !== 1
|
||||||
|
? 's'
|
||||||
|
: ''}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue