This commit is contained in:
Justin Edmund 2025-06-13 21:22:49 -04:00
parent b3979008ae
commit cc6eba7df1
16 changed files with 260 additions and 237 deletions

View file

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

View file

@ -3,4 +3,4 @@
@import './variables.scss'; @import './variables.scss';
@import './fonts.scss'; @import './fonts.scss';
@import './themes.scss'; @import './themes.scss';

View file

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

View file

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

View file

@ -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>({
}) })
] ]
} }
}) })

View file

@ -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>({
}) })
] ]
} }
}) })

View file

@ -49,4 +49,4 @@ export const UrlEmbedExtended = (component: any) =>
addNodeView() { addNodeView() {
return SvelteNodeViewRenderer(component) return SvelteNodeViewRenderer(component)
} }
}) })

View file

@ -32,4 +32,4 @@ export const UrlEmbedPlaceholder = (component: any) =>
addNodeView() { addNodeView() {
return SvelteNodeViewRenderer(component) return SvelteNodeViewRenderer(component)
} }
}) })

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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