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
|
||||
|
||||
1. PostgreSQL client tools must be installed (`pg_dump`, `psql`)
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install postgresql
|
||||
|
|
@ -82,6 +83,7 @@ npm run db:backup:sync
|
|||
```
|
||||
|
||||
2. Set environment variables in `.env` or `.env.local`:
|
||||
|
||||
```bash
|
||||
# Required for local database operations
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/dbname"
|
||||
|
|
@ -123,18 +125,23 @@ npm run db:restore ./backups/backup_file.sql.gz remote
|
|||
### Common Workflows
|
||||
|
||||
#### Daily Development
|
||||
|
||||
Start your day by syncing the production database to local:
|
||||
|
||||
```bash
|
||||
npm run db:backup:sync
|
||||
```
|
||||
|
||||
#### Before Deploying Schema Changes
|
||||
|
||||
Always backup the remote database:
|
||||
|
||||
```bash
|
||||
npm run db:backup:remote
|
||||
```
|
||||
|
||||
#### Recover from Mistakes
|
||||
|
||||
```bash
|
||||
# See available backups
|
||||
npm run db:backups
|
||||
|
|
@ -146,6 +153,7 @@ npm run db:restore ./backups/local_20240615_143022.sql.gz
|
|||
### Backup Storage
|
||||
|
||||
All backups are stored in `./backups/` with timestamps:
|
||||
|
||||
- Local: `local_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",
|
||||
"dependencies": {
|
||||
"@aarkue/tiptap-math-extension": "^1.3.6",
|
||||
"@floating-ui/dom": "^1.7.1",
|
||||
"@prisma/client": "^6.8.2",
|
||||
"@sveltejs/adapter-node": "^5.2.0",
|
||||
"@tiptap/core": "^2.12.0",
|
||||
|
|
@ -677,6 +678,28 @@
|
|||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@
|
|||
"type": "module",
|
||||
"dependencies": {
|
||||
"@aarkue/tiptap-math-extension": "^1.3.6",
|
||||
"@floating-ui/dom": "^1.7.1",
|
||||
"@prisma/client": "^6.8.2",
|
||||
"@sveltejs/adapter-node": "^5.2.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;
|
||||
}
|
||||
|
||||
|
||||
.form-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -1,86 +1,207 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte'
|
||||
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 {
|
||||
isOpen: boolean
|
||||
triggerElement?: HTMLElement
|
||||
items: DropdownItem[]
|
||||
onClose?: () => void
|
||||
isSubmenu?: boolean
|
||||
}
|
||||
|
||||
interface DropdownItem {
|
||||
id: string
|
||||
label: string
|
||||
action: () => void
|
||||
label?: string
|
||||
action?: () => void
|
||||
variant?: 'default' | 'danger'
|
||||
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 cleanup: (() => void) | null = null
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
// Calculate position dynamically when needed
|
||||
const position = $derived(() => {
|
||||
if (!isOpen || !triggerElement || !browser) {
|
||||
return { top: 0, left: 0 }
|
||||
}
|
||||
// Track which submenu is open
|
||||
let openSubmenuId = $state<string | null>(null)
|
||||
let submenuElements = $state<Map<string, HTMLElement>>(new Map())
|
||||
let submenuCloseTimeout: number | null = null
|
||||
|
||||
const rect = triggerElement.getBoundingClientRect()
|
||||
const dropdownWidth = 180
|
||||
// Position state
|
||||
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 {
|
||||
top: rect.bottom + 4,
|
||||
left: rect.right - dropdownWidth
|
||||
destroy() {
|
||||
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) {
|
||||
event.stopPropagation()
|
||||
if (item.action && !item.children) {
|
||||
item.action()
|
||||
isOpen = false
|
||||
openSubmenuId = null // Reset submenu state
|
||||
onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
if (!dropdownElement || !isOpen) return
|
||||
|
||||
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
|
||||
openSubmenuId = null // Reset submenu state
|
||||
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(() => {
|
||||
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)
|
||||
|
||||
return () => {
|
||||
cleanup?.()
|
||||
cleanup = null
|
||||
document.removeEventListener('click', handleOutsideClick)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Reset submenu state when dropdown closes
|
||||
$effect(() => {
|
||||
if (!isOpen) {
|
||||
openSubmenuId = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if isOpen && browser}
|
||||
<div
|
||||
bind:this={dropdownElement}
|
||||
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}
|
||||
{#if item.divider}
|
||||
<div class="dropdown-divider"></div>
|
||||
{:else}
|
||||
<button
|
||||
use:submenuRef={{ item, submenuElements }}
|
||||
class="dropdown-item"
|
||||
class:danger={item.variant === 'danger'}
|
||||
class:has-children={item.children}
|
||||
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>
|
||||
|
||||
{#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}
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -90,7 +211,6 @@
|
|||
@import '$styles/variables.scss';
|
||||
|
||||
.dropdown-menu {
|
||||
position: fixed;
|
||||
background: white;
|
||||
border: 1px solid $grey-85;
|
||||
border-radius: $unit;
|
||||
|
|
@ -98,6 +218,8 @@
|
|||
overflow: hidden;
|
||||
min-width: 180px;
|
||||
z-index: 1050;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
|
|
@ -110,7 +232,9 @@
|
|||
color: $grey-20;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
display: block;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
&:hover {
|
||||
background-color: $grey-95;
|
||||
|
|
@ -119,6 +243,38 @@
|
|||
&.danger {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
import LinkContextMenuComponent from '$lib/components/edra/headless/components/LinkContextMenu.svelte'
|
||||
import LinkEditDialog from '$lib/components/edra/headless/components/LinkEditDialog.svelte'
|
||||
import UnifiedMediaModal from './UnifiedMediaModal.svelte'
|
||||
import DragHandle from '$lib/components/edra/drag-handle.svelte'
|
||||
import { mediaSelectionStore } from '$lib/stores/media-selection'
|
||||
import type { Media } from '@prisma/client'
|
||||
|
||||
|
|
@ -629,7 +630,6 @@
|
|||
return () => editor?.destroy()
|
||||
})
|
||||
|
||||
|
||||
// Public API
|
||||
export function save(): JSONContent | null {
|
||||
return editor?.getJSON() || null
|
||||
|
|
@ -789,6 +789,10 @@
|
|||
class:with-toolbar={showToolbar}
|
||||
style={`min-height: ${minHeight}px`}
|
||||
></div>
|
||||
|
||||
{#if editor}
|
||||
<DragHandle {editor} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- 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 {
|
||||
position: relative;
|
||||
|
|
@ -1226,4 +1315,33 @@
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -546,9 +546,7 @@
|
|||
</div>
|
||||
|
||||
<div class="footer-right">
|
||||
<Button variant="primary" onclick={handleSave} disabled={isSaving}>
|
||||
Save Changes
|
||||
</Button>
|
||||
<Button variant="primary" onclick={handleSave} disabled={isSaving}>Save Changes</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -90,7 +90,9 @@
|
|||
return
|
||||
}
|
||||
|
||||
const loadingToastId = toast.loading(`${status === 'published' ? 'Publishing' : 'Saving'} photo post...`)
|
||||
const loadingToastId = toast.loading(
|
||||
`${status === 'published' ? 'Publishing' : 'Saving'} photo post...`
|
||||
)
|
||||
|
||||
try {
|
||||
isSaving = true
|
||||
|
|
|
|||
|
|
@ -63,7 +63,9 @@
|
|||
return
|
||||
}
|
||||
|
||||
const loadingToastId = toast.loading(`${publishStatus === 'published' ? 'Publishing' : 'Saving'} post...`)
|
||||
const loadingToastId = toast.loading(
|
||||
`${publishStatus === 'published' ? 'Publishing' : 'Saving'} post...`
|
||||
)
|
||||
|
||||
try {
|
||||
isSaving = true
|
||||
|
|
|
|||
|
|
@ -1,31 +1,455 @@
|
|||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core'
|
||||
import type { Node } from '@tiptap/pm/model'
|
||||
import { onMount } from 'svelte'
|
||||
import GripVertical from 'lucide-svelte/icons/grip-vertical'
|
||||
import { DragHandlePlugin } from './extensions/drag-handle/index.js'
|
||||
import DropdownMenu from '../admin/DropdownMenu.svelte'
|
||||
|
||||
interface Props {
|
||||
editor: Editor
|
||||
}
|
||||
|
||||
interface DropdownItem {
|
||||
id: string
|
||||
label?: string
|
||||
action?: () => void
|
||||
variant?: 'default' | 'danger'
|
||||
divider?: boolean
|
||||
children?: DropdownItem[]
|
||||
icon?: string
|
||||
}
|
||||
|
||||
const { editor }: Props = $props()
|
||||
|
||||
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(() => {
|
||||
// Register the plugin with onMouseMove callback
|
||||
const plugin = DragHandlePlugin({
|
||||
pluginKey: pluginKey,
|
||||
dragHandleWidth: 20,
|
||||
dragHandleWidth: 24,
|
||||
scrollTreshold: 100,
|
||||
dragHandleSelector: '.drag-handle',
|
||||
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)
|
||||
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>
|
||||
|
||||
<div class="drag-handle">
|
||||
<GripVertical />
|
||||
</div>
|
||||
{#if dragHandleContainer}
|
||||
<DropdownMenu
|
||||
bind:isOpen={isMenuOpen}
|
||||
triggerElement={dragHandleContainer}
|
||||
items={menuItems()}
|
||||
onClose={() => {
|
||||
console.log('Dropdown closed')
|
||||
isMenuOpen = false
|
||||
}}
|
||||
/>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -117,7 +117,6 @@ export const initiateEditor = (
|
|||
limit
|
||||
}),
|
||||
SearchAndReplace,
|
||||
|
||||
...(extensions ?? [])
|
||||
],
|
||||
autofocus: true,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { NodeSelection, Plugin, PluginKey, TextSelection } from '@tiptap/pm/stat
|
|||
import { Fragment, Slice, Node } from '@tiptap/pm/model'
|
||||
import { EditorView } from '@tiptap/pm/view'
|
||||
import { serializeForClipboard } from './ClipboardSerializer.js'
|
||||
import DragHandleIcon from '$icons/drag-handle.svg?raw'
|
||||
|
||||
export interface GlobalDragHandleOptions {
|
||||
/**
|
||||
|
|
@ -70,6 +71,9 @@ function nodeDOMAtCoords(coords: { x: number; y: number }, options: GlobalDragHa
|
|||
'h4',
|
||||
'h5',
|
||||
'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}]`)
|
||||
].join(', ')
|
||||
return document
|
||||
|
|
@ -192,7 +196,11 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
|||
const relatedTarget = event.relatedTarget as HTMLElement
|
||||
const isInsideEditor =
|
||||
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
|
||||
}
|
||||
|
|
@ -210,6 +218,11 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
|||
dragHandleElement.dataset.dragHandle = ''
|
||||
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) {
|
||||
handleDragStart(e, view)
|
||||
}
|
||||
|
|
@ -254,6 +267,18 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
|||
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(
|
||||
{
|
||||
x: event.clientX + 50 + options.dragHandleWidth,
|
||||
|
|
@ -274,21 +299,34 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
|||
if (nodePos !== undefined) {
|
||||
const currentNode = view.state.doc.nodeAt(nodePos)
|
||||
if (currentNode !== null) {
|
||||
// Still update the current node for tracking, but don't reposition if menu is open
|
||||
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 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 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
|
||||
|
|
@ -297,8 +335,9 @@ export function DragHandlePlugin(options: GlobalDragHandleOptions & { pluginKey:
|
|||
|
||||
if (!dragHandleElement) return
|
||||
|
||||
dragHandleElement.style.left = `${rect.left - rect.width}px`
|
||||
dragHandleElement.style.top = `${rect.top}px`
|
||||
// Add 8px gap between drag handle and content
|
||||
dragHandleElement.style.left = `${rect.left - rect.width - 8}px`
|
||||
dragHandleElement.style.top = `${rect.top - 4}px` // Offset for padding
|
||||
showDragHandle()
|
||||
},
|
||||
keydown: () => {
|
||||
|
|
|
|||
|
|
@ -27,8 +27,8 @@ export const ImageExtended = (component: Component<NodeViewProps>): Node<ImageOp
|
|||
},
|
||||
mediaId: {
|
||||
default: null,
|
||||
parseHTML: element => element.getAttribute('data-media-id'),
|
||||
renderHTML: attributes => {
|
||||
parseHTML: (element) => element.getAttribute('data-media-id'),
|
||||
renderHTML: (attributes) => {
|
||||
if (!attributes.mediaId) {
|
||||
return {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -137,7 +137,6 @@
|
|||
// Set a reasonable default width (max 600px)
|
||||
const displayWidth = media.width && media.width > 600 ? 600 : media.width
|
||||
|
||||
|
||||
const imageAttrs = {
|
||||
src: media.url,
|
||||
alt: media.altText || '',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import { toast as sonnerToast } from 'svelte-sonner'
|
|||
|
||||
export interface ToastOptions {
|
||||
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
|
||||
action?: {
|
||||
label: string
|
||||
|
|
|
|||
|
|
@ -170,7 +170,9 @@
|
|||
<span class="meta-item">📍 {album.location}</span>
|
||||
{/if}
|
||||
<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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue