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:
Justin Edmund 2025-06-25 01:06:46 +01:00
parent fb01527469
commit a04d48e549
21 changed files with 851 additions and 64 deletions

View file

@ -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
View file

@ -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",

View file

@ -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",

View 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

View 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

View file

@ -464,7 +464,6 @@
color: $grey-40; color: $grey-40;
} }
.form-section { .form-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View file

@ -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 {

View file

@ -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>

View file

@ -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>

View file

@ -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

View file

@ -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

View file

@ -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}

View file

@ -117,7 +117,6 @@ export const initiateEditor = (
limit limit
}), }),
SearchAndReplace, SearchAndReplace,
...(extensions ?? []) ...(extensions ?? [])
], ],
autofocus: true, autofocus: true,

View file

@ -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: () => {

View file

@ -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 {}
} }

View file

@ -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 || '',

View file

@ -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

View file

@ -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>