Linter
This commit is contained in:
parent
b3979008ae
commit
cc6eba7df1
16 changed files with 260 additions and 237 deletions
|
|
@ -1,3 +1,3 @@
|
||||||
/* Global styles for the entire application */
|
/* Global styles for the entire application */
|
||||||
@import './assets/styles/reset.css';
|
@import './assets/styles/reset.css';
|
||||||
@import './assets/styles/globals.scss';
|
@import './assets/styles/globals.scss';
|
||||||
|
|
|
||||||
|
|
@ -3,4 +3,4 @@
|
||||||
|
|
||||||
@import './variables.scss';
|
@import './variables.scss';
|
||||||
@import './fonts.scss';
|
@import './fonts.scss';
|
||||||
@import './themes.scss';
|
@import './themes.scss';
|
||||||
|
|
|
||||||
|
|
@ -119,9 +119,7 @@
|
||||||
onmouseenter={() => (hoveredIndex = index)}
|
onmouseenter={() => (hoveredIndex = index)}
|
||||||
onmouseleave={() => (hoveredIndex = null)}
|
onmouseleave={() => (hoveredIndex = null)}
|
||||||
>
|
>
|
||||||
<item.icon
|
<item.icon class="nav-icon {hoveredIndex === index ? 'animate' : ''}" />
|
||||||
class="nav-icon {hoveredIndex === index ? 'animate' : ''}"
|
|
||||||
/>
|
|
||||||
<span>{item.text}</span>
|
<span>{item.text}</span>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
|
|
@ -83,18 +83,18 @@
|
||||||
let mediaDropdownTriggerRef = $state<HTMLElement>()
|
let mediaDropdownTriggerRef = $state<HTMLElement>()
|
||||||
let dropdownPosition = $state({ top: 0, left: 0 })
|
let dropdownPosition = $state({ top: 0, left: 0 })
|
||||||
let mediaDropdownPosition = $state({ top: 0, left: 0 })
|
let mediaDropdownPosition = $state({ top: 0, left: 0 })
|
||||||
|
|
||||||
// URL convert dropdown state
|
// URL convert dropdown state
|
||||||
let showUrlConvertDropdown = $state(false)
|
let showUrlConvertDropdown = $state(false)
|
||||||
let urlConvertDropdownPosition = $state({ x: 0, y: 0 })
|
let urlConvertDropdownPosition = $state({ x: 0, y: 0 })
|
||||||
let urlConvertPos = $state<number | null>(null)
|
let urlConvertPos = $state<number | null>(null)
|
||||||
|
|
||||||
// Link context menu state
|
// Link context menu state
|
||||||
let showLinkContextMenu = $state(false)
|
let showLinkContextMenu = $state(false)
|
||||||
let linkContextMenuPosition = $state({ x: 0, y: 0 })
|
let linkContextMenuPosition = $state({ x: 0, y: 0 })
|
||||||
let linkContextUrl = $state<string | null>(null)
|
let linkContextUrl = $state<string | null>(null)
|
||||||
let linkContextPos = $state<number | null>(null)
|
let linkContextPos = $state<number | null>(null)
|
||||||
|
|
||||||
// Link edit dialog state
|
// Link edit dialog state
|
||||||
let showLinkEditDialog = $state(false)
|
let showLinkEditDialog = $state(false)
|
||||||
let linkEditDialogPosition = $state({ x: 0, y: 0 })
|
let linkEditDialogPosition = $state({ x: 0, y: 0 })
|
||||||
|
|
@ -239,85 +239,89 @@
|
||||||
showLinkEditDialog = false
|
showLinkEditDialog = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle URL convert dropdown
|
// Handle URL convert dropdown
|
||||||
const handleShowUrlConvertDropdown = (pos: number, url: string) => {
|
const handleShowUrlConvertDropdown = (pos: number, url: string) => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|
||||||
// Get the cursor coordinates
|
// Get the cursor coordinates
|
||||||
const coords = editor.view.coordsAtPos(pos)
|
const coords = editor.view.coordsAtPos(pos)
|
||||||
urlConvertDropdownPosition = { x: coords.left, y: coords.bottom + 5 }
|
urlConvertDropdownPosition = { x: coords.left, y: coords.bottom + 5 }
|
||||||
urlConvertPos = pos
|
urlConvertPos = pos
|
||||||
showUrlConvertDropdown = true
|
showUrlConvertDropdown = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle link context menu
|
// Handle link context menu
|
||||||
const handleShowLinkContextMenu = (pos: number, url: string, coords: { x: number, y: number }) => {
|
const handleShowLinkContextMenu = (
|
||||||
|
pos: number,
|
||||||
|
url: string,
|
||||||
|
coords: { x: number; y: number }
|
||||||
|
) => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|
||||||
linkContextMenuPosition = { x: coords.x, y: coords.y + 5 }
|
linkContextMenuPosition = { x: coords.x, y: coords.y + 5 }
|
||||||
linkContextUrl = url
|
linkContextUrl = url
|
||||||
linkContextPos = pos
|
linkContextPos = pos
|
||||||
showLinkContextMenu = true
|
showLinkContextMenu = true
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConvertToEmbed = () => {
|
const handleConvertToEmbed = () => {
|
||||||
if (!editor || urlConvertPos === null) return
|
if (!editor || urlConvertPos === null) return
|
||||||
|
|
||||||
editor.commands.convertLinkToEmbed(urlConvertPos)
|
editor.commands.convertLinkToEmbed(urlConvertPos)
|
||||||
showUrlConvertDropdown = false
|
showUrlConvertDropdown = false
|
||||||
urlConvertPos = null
|
urlConvertPos = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleConvertLinkToEmbed = () => {
|
const handleConvertLinkToEmbed = () => {
|
||||||
if (!editor || linkContextPos === null) return
|
if (!editor || linkContextPos === null) return
|
||||||
|
|
||||||
editor.commands.convertLinkToEmbed(linkContextPos)
|
editor.commands.convertLinkToEmbed(linkContextPos)
|
||||||
showLinkContextMenu = false
|
showLinkContextMenu = false
|
||||||
linkContextPos = null
|
linkContextPos = null
|
||||||
linkContextUrl = null
|
linkContextUrl = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEditLink = () => {
|
const handleEditLink = () => {
|
||||||
if (!editor || !linkContextUrl) return
|
if (!editor || !linkContextUrl) return
|
||||||
|
|
||||||
linkEditUrl = linkContextUrl
|
linkEditUrl = linkContextUrl
|
||||||
linkEditPos = linkContextPos
|
linkEditPos = linkContextPos
|
||||||
linkEditDialogPosition = { ...linkContextMenuPosition }
|
linkEditDialogPosition = { ...linkContextMenuPosition }
|
||||||
showLinkEditDialog = true
|
showLinkEditDialog = true
|
||||||
showLinkContextMenu = false
|
showLinkContextMenu = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSaveLink = (newUrl: string) => {
|
const handleSaveLink = (newUrl: string) => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|
||||||
editor.chain().focus().extendMarkRange('link').setLink({ href: newUrl }).run()
|
editor.chain().focus().extendMarkRange('link').setLink({ href: newUrl }).run()
|
||||||
showLinkEditDialog = false
|
showLinkEditDialog = false
|
||||||
linkEditPos = null
|
linkEditPos = null
|
||||||
linkEditUrl = ''
|
linkEditUrl = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
if (!linkContextUrl) return
|
if (!linkContextUrl) return
|
||||||
|
|
||||||
navigator.clipboard.writeText(linkContextUrl)
|
navigator.clipboard.writeText(linkContextUrl)
|
||||||
showLinkContextMenu = false
|
showLinkContextMenu = false
|
||||||
linkContextPos = null
|
linkContextPos = null
|
||||||
linkContextUrl = null
|
linkContextUrl = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRemoveLink = () => {
|
const handleRemoveLink = () => {
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
|
|
||||||
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
||||||
showLinkContextMenu = false
|
showLinkContextMenu = false
|
||||||
linkContextPos = null
|
linkContextPos = null
|
||||||
linkContextUrl = null
|
linkContextUrl = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenLink = () => {
|
const handleOpenLink = () => {
|
||||||
if (!linkContextUrl) return
|
if (!linkContextUrl) return
|
||||||
|
|
||||||
window.open(linkContextUrl, '_blank', 'noopener,noreferrer')
|
window.open(linkContextUrl, '_blank', 'noopener,noreferrer')
|
||||||
showLinkContextMenu = false
|
showLinkContextMenu = false
|
||||||
linkContextPos = null
|
linkContextPos = null
|
||||||
|
|
@ -325,7 +329,13 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (showTextStyleDropdown || showMediaDropdown || showUrlConvertDropdown || showLinkContextMenu || showLinkEditDialog) {
|
if (
|
||||||
|
showTextStyleDropdown ||
|
||||||
|
showMediaDropdown ||
|
||||||
|
showUrlConvertDropdown ||
|
||||||
|
showLinkContextMenu ||
|
||||||
|
showLinkEditDialog
|
||||||
|
) {
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
|
|
@ -484,16 +494,16 @@
|
||||||
// Dismiss URL convert dropdown if user types
|
// Dismiss URL convert dropdown if user types
|
||||||
if (showUrlConvertDropdown && transaction.docChanged) {
|
if (showUrlConvertDropdown && transaction.docChanged) {
|
||||||
// Check if the change is actual typing (not just cursor movement)
|
// Check if the change is actual typing (not just cursor movement)
|
||||||
const hasTextChange = transaction.steps.some(step =>
|
const hasTextChange = transaction.steps.some(
|
||||||
step.toJSON().stepType === 'replace' ||
|
(step) =>
|
||||||
step.toJSON().stepType === 'replaceAround'
|
step.toJSON().stepType === 'replace' || step.toJSON().stepType === 'replaceAround'
|
||||||
)
|
)
|
||||||
if (hasTextChange) {
|
if (hasTextChange) {
|
||||||
showUrlConvertDropdown = false
|
showUrlConvertDropdown = false
|
||||||
urlConvertPos = null
|
urlConvertPos = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Call the original onUpdate if provided
|
// Call the original onUpdate if provided
|
||||||
if (onUpdate) {
|
if (onUpdate) {
|
||||||
onUpdate({ editor: updatedEditor, transaction })
|
onUpdate({ editor: updatedEditor, transaction })
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Extension } from '@tiptap/core'
|
||||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||||
|
|
||||||
export interface LinkContextMenuOptions {
|
export interface LinkContextMenuOptions {
|
||||||
onShowContextMenu?: (pos: number, url: string, coords: { x: number, y: number }) => void
|
onShowContextMenu?: (pos: number, url: string, coords: { x: number; y: number }) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LinkContextMenu = Extension.create<LinkContextMenuOptions>({
|
export const LinkContextMenu = Extension.create<LinkContextMenuOptions>({
|
||||||
|
|
@ -16,7 +16,7 @@ export const LinkContextMenu = Extension.create<LinkContextMenuOptions>({
|
||||||
|
|
||||||
addProseMirrorPlugins() {
|
addProseMirrorPlugins() {
|
||||||
const options = this.options
|
const options = this.options
|
||||||
|
|
||||||
return [
|
return [
|
||||||
new Plugin({
|
new Plugin({
|
||||||
key: new PluginKey('linkContextMenu'),
|
key: new PluginKey('linkContextMenu'),
|
||||||
|
|
@ -25,26 +25,26 @@ export const LinkContextMenu = Extension.create<LinkContextMenuOptions>({
|
||||||
contextmenu: (view, event) => {
|
contextmenu: (view, event) => {
|
||||||
const { state } = view
|
const { state } = view
|
||||||
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
|
const pos = view.posAtCoords({ left: event.clientX, top: event.clientY })
|
||||||
|
|
||||||
if (!pos) return false
|
if (!pos) return false
|
||||||
|
|
||||||
const $pos = state.doc.resolve(pos.pos)
|
const $pos = state.doc.resolve(pos.pos)
|
||||||
const marks = $pos.marks()
|
const marks = $pos.marks()
|
||||||
const linkMark = marks.find(mark => mark.type.name === 'link')
|
const linkMark = marks.find((mark) => mark.type.name === 'link')
|
||||||
|
|
||||||
if (linkMark && linkMark.attrs.href) {
|
if (linkMark && linkMark.attrs.href) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
||||||
if (options.onShowContextMenu) {
|
if (options.onShowContextMenu) {
|
||||||
options.onShowContextMenu(pos.pos, linkMark.attrs.href, {
|
options.onShowContextMenu(pos.pos, linkMark.attrs.href, {
|
||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
y: event.clientY
|
y: event.clientY
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -52,4 +52,4 @@ export const LinkContextMenu = Extension.create<LinkContextMenuOptions>({
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,10 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
|
||||||
},
|
},
|
||||||
|
|
||||||
renderHTML({ HTMLAttributes }) {
|
renderHTML({ HTMLAttributes }) {
|
||||||
return ['div', mergeAttributes({ 'data-url-embed': '' }, this.options.HTMLAttributes, HTMLAttributes)]
|
return [
|
||||||
|
'div',
|
||||||
|
mergeAttributes({ 'data-url-embed': '' }, this.options.HTMLAttributes, HTMLAttributes)
|
||||||
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
addCommands() {
|
addCommands() {
|
||||||
|
|
@ -102,35 +105,41 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
|
||||||
(pos) =>
|
(pos) =>
|
||||||
({ state, commands, chain }) => {
|
({ state, commands, chain }) => {
|
||||||
const { doc } = state
|
const { doc } = state
|
||||||
|
|
||||||
// Find the link mark at the given position
|
// Find the link mark at the given position
|
||||||
const $pos = doc.resolve(pos)
|
const $pos = doc.resolve(pos)
|
||||||
const marks = $pos.marks()
|
const marks = $pos.marks()
|
||||||
const linkMark = marks.find(mark => mark.type.name === 'link')
|
const linkMark = marks.find((mark) => mark.type.name === 'link')
|
||||||
|
|
||||||
if (!linkMark) return false
|
if (!linkMark) return false
|
||||||
|
|
||||||
const url = linkMark.attrs.href
|
const url = linkMark.attrs.href
|
||||||
if (!url) return false
|
if (!url) return false
|
||||||
|
|
||||||
// Find the complete range of text with this link mark
|
// Find the complete range of text with this link mark
|
||||||
let from = pos
|
let from = pos
|
||||||
let to = pos
|
let to = pos
|
||||||
|
|
||||||
// Walk backwards to find the start
|
// Walk backwards to find the start
|
||||||
doc.nodesBetween(Math.max(0, pos - 300), pos, (node, nodePos) => {
|
doc.nodesBetween(Math.max(0, pos - 300), pos, (node, nodePos) => {
|
||||||
if (node.isText && node.marks.some(m => m.type.name === 'link' && m.attrs.href === url)) {
|
if (
|
||||||
|
node.isText &&
|
||||||
|
node.marks.some((m) => m.type.name === 'link' && m.attrs.href === url)
|
||||||
|
) {
|
||||||
from = nodePos
|
from = nodePos
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Walk forwards to find the end
|
// Walk forwards to find the end
|
||||||
doc.nodesBetween(pos, Math.min(doc.content.size, pos + 300), (node, nodePos) => {
|
doc.nodesBetween(pos, Math.min(doc.content.size, pos + 300), (node, nodePos) => {
|
||||||
if (node.isText && node.marks.some(m => m.type.name === 'link' && m.attrs.href === url)) {
|
if (
|
||||||
|
node.isText &&
|
||||||
|
node.marks.some((m) => m.type.name === 'link' && m.attrs.href === url)
|
||||||
|
) {
|
||||||
to = nodePos + node.nodeSize
|
to = nodePos + node.nodeSize
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Use Tiptap's chain commands to replace content
|
// Use Tiptap's chain commands to replace content
|
||||||
return chain()
|
return chain()
|
||||||
.focus()
|
.focus()
|
||||||
|
|
@ -179,40 +188,53 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
|
||||||
// Check if it's a plain text paste
|
// Check if it's a plain text paste
|
||||||
if (text && !html) {
|
if (text && !html) {
|
||||||
// Simple URL regex check
|
// Simple URL regex check
|
||||||
const urlRegex = /^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/
|
const urlRegex =
|
||||||
|
/^https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)$/
|
||||||
|
|
||||||
if (urlRegex.test(text.trim())) {
|
if (urlRegex.test(text.trim())) {
|
||||||
// It's a URL, let it paste as a link naturally (don't prevent default)
|
// It's a URL, let it paste as a link naturally (don't prevent default)
|
||||||
// But track it so we can show dropdown after
|
// But track it so we can show dropdown after
|
||||||
const pastedUrl = text.trim()
|
const pastedUrl = text.trim()
|
||||||
|
|
||||||
// Get the position before paste
|
// Get the position before paste
|
||||||
const beforePos = view.state.selection.from
|
const beforePos = view.state.selection.from
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const { state } = view
|
const { state } = view
|
||||||
const { doc } = state
|
const { doc } = state
|
||||||
|
|
||||||
// Find the link that was just inserted
|
// Find the link that was just inserted
|
||||||
// Start from where we were before paste
|
// Start from where we were before paste
|
||||||
let linkStart = -1
|
let linkStart = -1
|
||||||
let linkEnd = -1
|
let linkEnd = -1
|
||||||
|
|
||||||
// Search for the link in a reasonable range
|
// Search for the link in a reasonable range
|
||||||
for (let pos = beforePos; pos < Math.min(doc.content.size, beforePos + pastedUrl.length + 10); pos++) {
|
for (
|
||||||
|
let pos = beforePos;
|
||||||
|
pos < Math.min(doc.content.size, beforePos + pastedUrl.length + 10);
|
||||||
|
pos++
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
const $pos = doc.resolve(pos)
|
const $pos = doc.resolve(pos)
|
||||||
const marks = $pos.marks()
|
const marks = $pos.marks()
|
||||||
const linkMark = marks.find(m => m.type.name === 'link' && m.attrs.href === pastedUrl)
|
const linkMark = marks.find(
|
||||||
|
(m) => m.type.name === 'link' && m.attrs.href === pastedUrl
|
||||||
|
)
|
||||||
|
|
||||||
if (linkMark) {
|
if (linkMark) {
|
||||||
// Found the link, now find its boundaries
|
// Found the link, now find its boundaries
|
||||||
linkStart = pos
|
linkStart = pos
|
||||||
|
|
||||||
// Find the end of the link
|
// Find the end of the link
|
||||||
for (let endPos = pos; endPos < Math.min(doc.content.size, pos + pastedUrl.length + 5); endPos++) {
|
for (
|
||||||
|
let endPos = pos;
|
||||||
|
endPos < Math.min(doc.content.size, pos + pastedUrl.length + 5);
|
||||||
|
endPos++
|
||||||
|
) {
|
||||||
const $endPos = doc.resolve(endPos)
|
const $endPos = doc.resolve(endPos)
|
||||||
const hasLink = $endPos.marks().some(m => m.type.name === 'link' && m.attrs.href === pastedUrl)
|
const hasLink = $endPos
|
||||||
|
.marks()
|
||||||
|
.some((m) => m.type.name === 'link' && m.attrs.href === pastedUrl)
|
||||||
if (hasLink) {
|
if (hasLink) {
|
||||||
linkEnd = endPos + 1
|
linkEnd = endPos + 1
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -225,7 +247,7 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
|
||||||
// Position might be invalid, continue
|
// Position might be invalid, continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (linkStart !== -1) {
|
if (linkStart !== -1) {
|
||||||
// Store the pasted URL info with correct position
|
// Store the pasted URL info with correct position
|
||||||
const tr = state.tr.setMeta('urlEmbedPaste', {
|
const tr = state.tr.setMeta('urlEmbedPaste', {
|
||||||
|
|
@ -233,7 +255,7 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
|
||||||
lastPastedPos: linkStart
|
lastPastedPos: linkStart
|
||||||
})
|
})
|
||||||
view.dispatch(tr)
|
view.dispatch(tr)
|
||||||
|
|
||||||
// Notify the editor to show dropdown
|
// Notify the editor to show dropdown
|
||||||
if (options.onShowDropdown) {
|
if (options.onShowDropdown) {
|
||||||
options.onShowDropdown(linkStart, pastedUrl)
|
options.onShowDropdown(linkStart, pastedUrl)
|
||||||
|
|
@ -251,4 +273,4 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
|
||||||
})
|
})
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -49,4 +49,4 @@ export const UrlEmbedExtended = (component: any) =>
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return SvelteNodeViewRenderer(component)
|
return SvelteNodeViewRenderer(component)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -32,4 +32,4 @@ export const UrlEmbedPlaceholder = (component: any) =>
|
||||||
addNodeView() {
|
addNodeView() {
|
||||||
return SvelteNodeViewRenderer(component)
|
return SvelteNodeViewRenderer(component)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import { fly } from 'svelte/transition'
|
import { fly } from 'svelte/transition'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
|
|
@ -13,29 +13,39 @@
|
||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
onDismiss: () => void
|
onDismiss: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let { x, y, url, onConvertToLink, onCopyLink, onRefresh, onOpenLink, onRemove, onDismiss }: Props = $props()
|
let {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
url,
|
||||||
|
onConvertToLink,
|
||||||
|
onCopyLink,
|
||||||
|
onRefresh,
|
||||||
|
onOpenLink,
|
||||||
|
onRemove,
|
||||||
|
onDismiss
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
let dropdown: HTMLDivElement
|
let dropdown: HTMLDivElement
|
||||||
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (dropdown && !dropdown.contains(event.target as Node)) {
|
if (dropdown && !dropdown.contains(event.target as Node)) {
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
document.addEventListener('keydown', handleKeydown)
|
document.addEventListener('keydown', handleKeydown)
|
||||||
dropdown?.focus()
|
dropdown?.focus()
|
||||||
})
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
document.removeEventListener('keydown', handleKeydown)
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
|
@ -51,28 +61,18 @@
|
||||||
>
|
>
|
||||||
<div class="menu-url">{url}</div>
|
<div class="menu-url">{url}</div>
|
||||||
<div class="menu-divider"></div>
|
<div class="menu-divider"></div>
|
||||||
|
|
||||||
<button class="menu-item" onclick={onOpenLink}>
|
<button class="menu-item" onclick={onOpenLink}> Open link </button>
|
||||||
Open link
|
|
||||||
</button>
|
<button class="menu-item" onclick={onCopyLink}> Copy link </button>
|
||||||
|
|
||||||
<button class="menu-item" onclick={onCopyLink}>
|
<button class="menu-item" onclick={onRefresh}> Refresh preview </button>
|
||||||
Copy link
|
|
||||||
</button>
|
<button class="menu-item" onclick={onConvertToLink}> Convert to link </button>
|
||||||
|
|
||||||
<button class="menu-item" onclick={onRefresh}>
|
|
||||||
Refresh preview
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="menu-item" onclick={onConvertToLink}>
|
|
||||||
Convert to link
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="menu-divider"></div>
|
<div class="menu-divider"></div>
|
||||||
|
|
||||||
<button class="menu-item danger" onclick={onRemove}>
|
<button class="menu-item danger" onclick={onRemove}> Remove card </button>
|
||||||
Remove card
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
@ -88,7 +88,7 @@
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-url {
|
.menu-url {
|
||||||
padding: $unit $unit-2x;
|
padding: $unit $unit-2x;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|
@ -97,13 +97,13 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-divider {
|
.menu-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background-color: $grey-90;
|
background-color: $grey-90;
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item {
|
.menu-item {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -116,18 +116,18 @@
|
||||||
color: $grey-20;
|
color: $grey-20;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $grey-95;
|
background-color: $grey-95;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: 2px solid $red-60;
|
outline: 2px solid $red-60;
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.danger {
|
&.danger {
|
||||||
color: $red-60;
|
color: $red-60;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import { fly } from 'svelte/transition'
|
import { fly } from 'svelte/transition'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
|
|
@ -13,29 +13,39 @@
|
||||||
onOpenLink: () => void
|
onOpenLink: () => void
|
||||||
onDismiss: () => void
|
onDismiss: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let { x, y, url, onConvertToCard, onEditLink, onCopyLink, onRemoveLink, onOpenLink, onDismiss }: Props = $props()
|
let {
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
url,
|
||||||
|
onConvertToCard,
|
||||||
|
onEditLink,
|
||||||
|
onCopyLink,
|
||||||
|
onRemoveLink,
|
||||||
|
onOpenLink,
|
||||||
|
onDismiss
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
let dropdown: HTMLDivElement
|
let dropdown: HTMLDivElement
|
||||||
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (dropdown && !dropdown.contains(event.target as Node)) {
|
if (dropdown && !dropdown.contains(event.target as Node)) {
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
document.addEventListener('keydown', handleKeydown)
|
document.addEventListener('keydown', handleKeydown)
|
||||||
dropdown?.focus()
|
dropdown?.focus()
|
||||||
})
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
document.removeEventListener('keydown', handleKeydown)
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
|
@ -51,28 +61,18 @@
|
||||||
>
|
>
|
||||||
<div class="menu-url">{url}</div>
|
<div class="menu-url">{url}</div>
|
||||||
<div class="menu-divider"></div>
|
<div class="menu-divider"></div>
|
||||||
|
|
||||||
<button class="menu-item" onclick={onOpenLink}>
|
<button class="menu-item" onclick={onOpenLink}> Open link </button>
|
||||||
Open link
|
|
||||||
</button>
|
<button class="menu-item" onclick={onEditLink}> Edit link </button>
|
||||||
|
|
||||||
<button class="menu-item" onclick={onEditLink}>
|
<button class="menu-item" onclick={onCopyLink}> Copy link </button>
|
||||||
Edit link
|
|
||||||
</button>
|
<button class="menu-item" onclick={onConvertToCard}> Convert to card </button>
|
||||||
|
|
||||||
<button class="menu-item" onclick={onCopyLink}>
|
|
||||||
Copy link
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="menu-item" onclick={onConvertToCard}>
|
|
||||||
Convert to card
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="menu-divider"></div>
|
<div class="menu-divider"></div>
|
||||||
|
|
||||||
<button class="menu-item danger" onclick={onRemoveLink}>
|
<button class="menu-item danger" onclick={onRemoveLink}> Remove link </button>
|
||||||
Remove link
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
@ -88,7 +88,7 @@
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
max-width: 300px;
|
max-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-url {
|
.menu-url {
|
||||||
padding: $unit $unit-2x;
|
padding: $unit $unit-2x;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|
@ -97,13 +97,13 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-divider {
|
.menu-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background-color: $grey-90;
|
background-color: $grey-90;
|
||||||
margin: 4px 0;
|
margin: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-item {
|
.menu-item {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -116,18 +116,18 @@
|
||||||
color: $grey-20;
|
color: $grey-20;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $grey-95;
|
background-color: $grey-95;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: 2px solid $red-60;
|
outline: 2px solid $red-60;
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.danger {
|
&.danger {
|
||||||
color: $red-60;
|
color: $red-60;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
import { fly } from 'svelte/transition'
|
import { fly } from 'svelte/transition'
|
||||||
import Check from 'lucide-svelte/icons/check'
|
import Check from 'lucide-svelte/icons/check'
|
||||||
import X from 'lucide-svelte/icons/x'
|
import X from 'lucide-svelte/icons/x'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
|
|
@ -11,13 +11,13 @@
|
||||||
onSave: (url: string) => void
|
onSave: (url: string) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let { x, y, currentUrl, onSave, onCancel }: Props = $props()
|
let { x, y, currentUrl, onSave, onCancel }: Props = $props()
|
||||||
|
|
||||||
let urlInput = $state(currentUrl)
|
let urlInput = $state(currentUrl)
|
||||||
let inputElement: HTMLInputElement
|
let inputElement: HTMLInputElement
|
||||||
let dialogElement: HTMLDivElement
|
let dialogElement: HTMLDivElement
|
||||||
|
|
||||||
const isValid = $derived(() => {
|
const isValid = $derived(() => {
|
||||||
if (!urlInput.trim()) return false
|
if (!urlInput.trim()) return false
|
||||||
try {
|
try {
|
||||||
|
|
@ -33,19 +33,19 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleSave() {
|
function handleSave() {
|
||||||
if (!isValid) return
|
if (!isValid) return
|
||||||
|
|
||||||
let finalUrl = urlInput.trim()
|
let finalUrl = urlInput.trim()
|
||||||
// Add https:// if no protocol
|
// Add https:// if no protocol
|
||||||
if (!finalUrl.match(/^https?:\/\//)) {
|
if (!finalUrl.match(/^https?:\/\//)) {
|
||||||
finalUrl = 'https://' + finalUrl
|
finalUrl = 'https://' + finalUrl
|
||||||
}
|
}
|
||||||
|
|
||||||
onSave(finalUrl)
|
onSave(finalUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Enter' && isValid) {
|
if (event.key === 'Enter' && isValid) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
onCancel()
|
onCancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
inputElement?.focus()
|
inputElement?.focus()
|
||||||
inputElement?.select()
|
inputElement?.select()
|
||||||
|
|
@ -79,19 +79,10 @@
|
||||||
class:invalid={urlInput && !isValid}
|
class:invalid={urlInput && !isValid}
|
||||||
/>
|
/>
|
||||||
<div class="dialog-actions">
|
<div class="dialog-actions">
|
||||||
<button
|
<button class="action-button save" onclick={handleSave} disabled={!isValid} title="Save">
|
||||||
class="action-button save"
|
|
||||||
onclick={handleSave}
|
|
||||||
disabled={!isValid}
|
|
||||||
title="Save"
|
|
||||||
>
|
|
||||||
<Check />
|
<Check />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="action-button cancel" onclick={onCancel} title="Cancel">
|
||||||
class="action-button cancel"
|
|
||||||
onclick={onCancel}
|
|
||||||
title="Cancel"
|
|
||||||
>
|
|
||||||
<X />
|
<X />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -110,13 +101,13 @@
|
||||||
outline: none;
|
outline: none;
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-content {
|
.dialog-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $unit;
|
gap: $unit;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.url-input {
|
.url-input {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: $unit $unit-2x;
|
padding: $unit $unit-2x;
|
||||||
|
|
@ -126,22 +117,22 @@
|
||||||
color: $grey-20;
|
color: $grey-20;
|
||||||
background: white;
|
background: white;
|
||||||
transition: border-color 0.2s;
|
transition: border-color 0.2s;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: $red-60;
|
border-color: $red-60;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.invalid {
|
&.invalid {
|
||||||
border-color: $red-60;
|
border-color: $red-60;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog-actions {
|
.dialog-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-button {
|
.action-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -155,35 +146,35 @@
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
color: $grey-40;
|
color: $grey-40;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
&:hover:not(:disabled) {
|
||||||
background-color: $grey-95;
|
background-color: $grey-95;
|
||||||
color: $grey-20;
|
color: $grey-20;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.save:not(:disabled) {
|
&.save:not(:disabled) {
|
||||||
color: $red-60;
|
color: $red-60;
|
||||||
border-color: $red-60;
|
border-color: $red-60;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $red-60;
|
background-color: $red-60;
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.cancel:hover {
|
&.cancel:hover {
|
||||||
color: $red-60;
|
color: $red-60;
|
||||||
border-color: $red-60;
|
border-color: $red-60;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,28 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte'
|
import { onMount, onDestroy } from 'svelte'
|
||||||
import { fly } from 'svelte/transition'
|
import { fly } from 'svelte/transition'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
onConvert: () => void
|
onConvert: () => void
|
||||||
onDismiss: () => void
|
onDismiss: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
let { x, y, onConvert, onDismiss }: Props = $props()
|
let { x, y, onConvert, onDismiss }: Props = $props()
|
||||||
|
|
||||||
let dropdown: HTMLDivElement
|
let dropdown: HTMLDivElement
|
||||||
|
|
||||||
function handleConvert() {
|
function handleConvert() {
|
||||||
onConvert()
|
onConvert()
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClickOutside(event: MouseEvent) {
|
function handleClickOutside(event: MouseEvent) {
|
||||||
if (dropdown && !dropdown.contains(event.target as Node)) {
|
if (dropdown && !dropdown.contains(event.target as Node)) {
|
||||||
onDismiss()
|
onDismiss()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeydown(event: KeyboardEvent) {
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
onDismiss()
|
onDismiss()
|
||||||
|
|
@ -30,16 +30,16 @@
|
||||||
handleConvert()
|
handleConvert()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Add event listeners
|
// Add event listeners
|
||||||
document.addEventListener('click', handleClickOutside)
|
document.addEventListener('click', handleClickOutside)
|
||||||
document.addEventListener('keydown', handleKeydown)
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
|
||||||
// Don't focus the dropdown - this steals focus from the editor
|
// Don't focus the dropdown - this steals focus from the editor
|
||||||
// dropdown?.focus()
|
// dropdown?.focus()
|
||||||
})
|
})
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
document.removeEventListener('click', handleClickOutside)
|
document.removeEventListener('click', handleClickOutside)
|
||||||
document.removeEventListener('keydown', handleKeydown)
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
|
@ -53,9 +53,7 @@
|
||||||
transition:fly={{ y: -10, duration: 200 }}
|
transition:fly={{ y: -10, duration: 200 }}
|
||||||
tabindex="-1"
|
tabindex="-1"
|
||||||
>
|
>
|
||||||
<button class="convert-button" onclick={handleConvert}>
|
<button class="convert-button" onclick={handleConvert}> Convert to card </button>
|
||||||
Convert to card
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
@ -70,7 +68,7 @@
|
||||||
outline: none;
|
outline: none;
|
||||||
min-width: 160px;
|
min-width: 160px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.convert-button {
|
.convert-button {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
@ -84,14 +82,14 @@
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
transition: background-color 0.2s;
|
transition: background-color 0.2s;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: $grey-95;
|
background-color: $grey-95;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: 2px solid $red-60;
|
outline: 2px solid $red-60;
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
const { editor, node, deleteNode, getPos }: NodeViewProps = $props()
|
const { editor, node, deleteNode, getPos }: NodeViewProps = $props()
|
||||||
|
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
let error = $state(false)
|
let error = $state(false)
|
||||||
let errorMessage = $state('')
|
let errorMessage = $state('')
|
||||||
|
|
@ -26,32 +26,29 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const metadata = await response.json()
|
const metadata = await response.json()
|
||||||
|
|
||||||
// Replace this placeholder with the actual URL embed
|
// Replace this placeholder with the actual URL embed
|
||||||
const pos = getPos()
|
const pos = getPos()
|
||||||
if (typeof pos === 'number') {
|
if (typeof pos === 'number') {
|
||||||
editor
|
editor
|
||||||
.chain()
|
.chain()
|
||||||
.focus()
|
.focus()
|
||||||
.insertContentAt(
|
.insertContentAt({ from: pos, to: pos + node.nodeSize }, [
|
||||||
{ from: pos, to: pos + node.nodeSize },
|
{
|
||||||
[
|
type: 'urlEmbed',
|
||||||
{
|
attrs: {
|
||||||
type: 'urlEmbed',
|
url: url,
|
||||||
attrs: {
|
title: metadata.title,
|
||||||
url: url,
|
description: metadata.description,
|
||||||
title: metadata.title,
|
image: metadata.image,
|
||||||
description: metadata.description,
|
favicon: metadata.favicon,
|
||||||
image: metadata.image,
|
siteName: metadata.siteName
|
||||||
favicon: metadata.favicon,
|
|
||||||
siteName: metadata.siteName
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: 'paragraph'
|
|
||||||
}
|
}
|
||||||
]
|
},
|
||||||
)
|
{
|
||||||
|
type: 'paragraph'
|
||||||
|
}
|
||||||
|
])
|
||||||
.run()
|
.run()
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -64,7 +61,7 @@
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (!inputUrl.trim()) return
|
if (!inputUrl.trim()) return
|
||||||
|
|
||||||
// Basic URL validation
|
// Basic URL validation
|
||||||
try {
|
try {
|
||||||
new URL(inputUrl)
|
new URL(inputUrl)
|
||||||
|
|
@ -88,7 +85,7 @@
|
||||||
function handleClick(e: MouseEvent) {
|
function handleClick(e: MouseEvent) {
|
||||||
if (!editor.isEditable) return
|
if (!editor.isEditable) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
if (!showInput) {
|
if (!showInput) {
|
||||||
showInput = true
|
showInput = true
|
||||||
}
|
}
|
||||||
|
|
@ -126,7 +123,13 @@
|
||||||
<AlertCircle class="placeholder-icon" />
|
<AlertCircle class="placeholder-icon" />
|
||||||
<div class="error-content">
|
<div class="error-content">
|
||||||
<span class="placeholder-text">{errorMessage}</span>
|
<span class="placeholder-text">{errorMessage}</span>
|
||||||
<button onclick={() => { showInput = true; error = false; }} class="retry-button">
|
<button
|
||||||
|
onclick={() => {
|
||||||
|
showInput = true
|
||||||
|
error = false
|
||||||
|
}}
|
||||||
|
class="retry-button"
|
||||||
|
>
|
||||||
Try another URL
|
Try another URL
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -167,7 +170,7 @@
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
background: white;
|
background: white;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--primary-color, #3b82f6);
|
border-color: var(--primary-color, #3b82f6);
|
||||||
|
|
@ -274,4 +277,4 @@
|
||||||
.animate-spin {
|
.animate-spin {
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -164,7 +164,7 @@ function renderTiptapContent(doc: any): string {
|
||||||
const image = node.attrs?.image || ''
|
const image = node.attrs?.image || ''
|
||||||
const favicon = node.attrs?.favicon || ''
|
const favicon = node.attrs?.favicon || ''
|
||||||
const siteName = node.attrs?.siteName || ''
|
const siteName = node.attrs?.siteName || ''
|
||||||
|
|
||||||
// Helper to get domain from URL
|
// Helper to get domain from URL
|
||||||
const getDomain = (url: string) => {
|
const getDomain = (url: string) => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -174,14 +174,14 @@ function renderTiptapContent(doc: any): string {
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to extract YouTube video ID
|
// Helper to extract YouTube video ID
|
||||||
const getYouTubeVideoId = (url: string): string | null => {
|
const getYouTubeVideoId = (url: string): string | null => {
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
||||||
/youtube\.com\/watch\?.*v=([^&\n?#]+)/
|
/youtube\.com\/watch\?.*v=([^&\n?#]+)/
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
const match = url.match(pattern)
|
const match = url.match(pattern)
|
||||||
if (match && match[1]) {
|
if (match && match[1]) {
|
||||||
|
|
@ -190,33 +190,34 @@ function renderTiptapContent(doc: any): string {
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if it's a YouTube URL
|
// Check if it's a YouTube URL
|
||||||
const isYouTube = /(?:youtube\.com|youtu\.be)/.test(url)
|
const isYouTube = /(?:youtube\.com|youtu\.be)/.test(url)
|
||||||
const videoId = isYouTube ? getYouTubeVideoId(url) : null
|
const videoId = isYouTube ? getYouTubeVideoId(url) : null
|
||||||
|
|
||||||
if (isYouTube && videoId) {
|
if (isYouTube && videoId) {
|
||||||
// Render YouTube embed
|
// Render YouTube embed
|
||||||
let embedHtml = '<div class="url-embed-rendered url-embed-youtube">'
|
let embedHtml = '<div class="url-embed-rendered url-embed-youtube">'
|
||||||
embedHtml += '<div class="youtube-embed-wrapper">'
|
embedHtml += '<div class="youtube-embed-wrapper">'
|
||||||
embedHtml += `<iframe src="https://www.youtube.com/embed/${videoId}" `
|
embedHtml += `<iframe src="https://www.youtube.com/embed/${videoId}" `
|
||||||
embedHtml += 'frameborder="0" '
|
embedHtml += 'frameborder="0" '
|
||||||
embedHtml += 'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" '
|
embedHtml +=
|
||||||
|
'allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" '
|
||||||
embedHtml += 'allowfullscreen>'
|
embedHtml += 'allowfullscreen>'
|
||||||
embedHtml += '</iframe>'
|
embedHtml += '</iframe>'
|
||||||
embedHtml += '</div>'
|
embedHtml += '</div>'
|
||||||
embedHtml += '</div>'
|
embedHtml += '</div>'
|
||||||
return embedHtml
|
return embedHtml
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regular URL embed for non-YouTube links
|
// Regular URL embed for non-YouTube links
|
||||||
let embedHtml = '<div class="url-embed-rendered">'
|
let embedHtml = '<div class="url-embed-rendered">'
|
||||||
embedHtml += `<a href="${url}" target="_blank" rel="noopener noreferrer" class="url-embed-link">`
|
embedHtml += `<a href="${url}" target="_blank" rel="noopener noreferrer" class="url-embed-link">`
|
||||||
|
|
||||||
if (image) {
|
if (image) {
|
||||||
embedHtml += `<div class="url-embed-image"><img src="${image}" alt="${title || 'Link preview'}" /></div>`
|
embedHtml += `<div class="url-embed-image"><img src="${image}" alt="${title || 'Link preview'}" /></div>`
|
||||||
}
|
}
|
||||||
|
|
||||||
embedHtml += '<div class="url-embed-text">'
|
embedHtml += '<div class="url-embed-text">'
|
||||||
embedHtml += '<div class="url-embed-meta">'
|
embedHtml += '<div class="url-embed-meta">'
|
||||||
if (favicon) {
|
if (favicon) {
|
||||||
|
|
@ -224,19 +225,19 @@ function renderTiptapContent(doc: any): string {
|
||||||
}
|
}
|
||||||
embedHtml += `<span class="url-embed-domain">${siteName || getDomain(url)}</span>`
|
embedHtml += `<span class="url-embed-domain">${siteName || getDomain(url)}</span>`
|
||||||
embedHtml += '</div>'
|
embedHtml += '</div>'
|
||||||
|
|
||||||
if (title) {
|
if (title) {
|
||||||
embedHtml += `<h3 class="url-embed-title">${title}</h3>`
|
embedHtml += `<h3 class="url-embed-title">${title}</h3>`
|
||||||
}
|
}
|
||||||
|
|
||||||
if (description) {
|
if (description) {
|
||||||
embedHtml += `<p class="url-embed-description">${description}</p>`
|
embedHtml += `<p class="url-embed-description">${description}</p>`
|
||||||
}
|
}
|
||||||
|
|
||||||
embedHtml += '</div>'
|
embedHtml += '</div>'
|
||||||
embedHtml += '</a>'
|
embedHtml += '</a>'
|
||||||
embedHtml += '</div>'
|
embedHtml += '</div>'
|
||||||
|
|
||||||
return embedHtml
|
return embedHtml
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ export function extractEmbeds(content: any): ExtractedEmbed[] {
|
||||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
||||||
/youtube\.com\/watch\?.*v=([^&\n?#]+)/
|
/youtube\.com\/watch\?.*v=([^&\n?#]+)/
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
const match = url.match(pattern)
|
const match = url.match(pattern)
|
||||||
if (match && match[1]) {
|
if (match && match[1]) {
|
||||||
|
|
@ -36,7 +36,7 @@ export function extractEmbeds(content: any): ExtractedEmbed[] {
|
||||||
if (node.type === 'urlEmbed' && node.attrs?.url) {
|
if (node.type === 'urlEmbed' && node.attrs?.url) {
|
||||||
const url = node.attrs.url
|
const url = node.attrs.url
|
||||||
const isYouTube = /(?:youtube\.com|youtu\.be)/.test(url)
|
const isYouTube = /(?:youtube\.com|youtu\.be)/.test(url)
|
||||||
|
|
||||||
if (isYouTube) {
|
if (isYouTube) {
|
||||||
const videoId = getYouTubeVideoId(url)
|
const videoId = getYouTubeVideoId(url)
|
||||||
if (videoId) {
|
if (videoId) {
|
||||||
|
|
@ -76,4 +76,4 @@ export function extractEmbeds(content: any): ExtractedEmbed[] {
|
||||||
}
|
}
|
||||||
|
|
||||||
return embeds
|
return embeds
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,10 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||||
try {
|
try {
|
||||||
// Check cache first (unless force refresh is requested)
|
// Check cache first (unless force refresh is requested)
|
||||||
const cacheKey = `og-metadata:${targetUrl}`
|
const cacheKey = `og-metadata:${targetUrl}`
|
||||||
|
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh) {
|
||||||
const cached = await redis.get(cacheKey)
|
const cached = await redis.get(cacheKey)
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
console.log(`Cache hit for ${targetUrl}`)
|
console.log(`Cache hit for ${targetUrl}`)
|
||||||
return json(JSON.parse(cached))
|
return json(JSON.parse(cached))
|
||||||
|
|
@ -33,7 +33,7 @@ export const GET: RequestHandler = async ({ url }) => {
|
||||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
||||||
/youtube\.com\/watch\?.*v=([^&\n?#]+)/
|
/youtube\.com\/watch\?.*v=([^&\n?#]+)/
|
||||||
]
|
]
|
||||||
|
|
||||||
let videoId = null
|
let videoId = null
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
const match = targetUrl.match(pattern)
|
const match = targetUrl.match(pattern)
|
||||||
|
|
@ -183,7 +183,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
// Check cache first - using same cache key format
|
// Check cache first - using same cache key format
|
||||||
const cacheKey = `og-metadata:${targetUrl}`
|
const cacheKey = `og-metadata:${targetUrl}`
|
||||||
const cached = await redis.get(cacheKey)
|
const cached = await redis.get(cacheKey)
|
||||||
|
|
||||||
if (cached) {
|
if (cached) {
|
||||||
console.log(`Cache hit for ${targetUrl} (POST)`)
|
console.log(`Cache hit for ${targetUrl} (POST)`)
|
||||||
const ogData = JSON.parse(cached)
|
const ogData = JSON.parse(cached)
|
||||||
|
|
@ -208,7 +208,7 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/)([^&\n?#]+)/,
|
||||||
/youtube\.com\/watch\?.*v=([^&\n?#]+)/
|
/youtube\.com\/watch\?.*v=([^&\n?#]+)/
|
||||||
]
|
]
|
||||||
|
|
||||||
let videoId = null
|
let videoId = null
|
||||||
for (const pattern of patterns) {
|
for (const pattern of patterns) {
|
||||||
const match = targetUrl.match(pattern)
|
const match = targetUrl.match(pattern)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue