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