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

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

@ -252,7 +252,11 @@
} }
// 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 }
@ -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,9 +494,9 @@
// 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

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>({
@ -30,7 +30,7 @@ export const LinkContextMenu = Extension.create<LinkContextMenuOptions>({
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()

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() {
@ -106,7 +109,7 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
// 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
@ -119,14 +122,20 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
// 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
} }
}) })
@ -179,7 +188,8 @@ 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)
@ -199,20 +209,32 @@ export const UrlEmbed = Node.create<UrlEmbedOptions>({
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 {

View file

@ -14,7 +14,17 @@
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
@ -52,27 +62,17 @@
<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}> <button class="menu-item" onclick={onCopyLink}> Copy link </button>
Copy link
</button>
<button class="menu-item" onclick={onRefresh}> <button class="menu-item" onclick={onRefresh}> Refresh preview </button>
Refresh preview
</button>
<button class="menu-item" onclick={onConvertToLink}> <button class="menu-item" onclick={onConvertToLink}> Convert to link </button>
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">

View file

@ -14,7 +14,17 @@
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
@ -52,27 +62,17 @@
<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}> <button class="menu-item" onclick={onEditLink}> Edit link </button>
Edit link
</button>
<button class="menu-item" onclick={onCopyLink}> <button class="menu-item" onclick={onCopyLink}> Copy link </button>
Copy link
</button>
<button class="menu-item" onclick={onConvertToCard}> <button class="menu-item" onclick={onConvertToCard}> Convert to card </button>
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">

View file

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

View file

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

View file

@ -33,25 +33,22 @@
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) {
@ -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>

View file

@ -201,7 +201,8 @@ function renderTiptapContent(doc: any): string {
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>'