feat: add tooltips to link bubble menu buttons

- Create reusable tooltip action using Tippy.js
- Add tooltips to link menu buttons (copy, edit, open, remove)
- Implement visual feedback with green flash animation on URL copy
- Configure bubble menu scale animation for smooth appearance
- Add scoped tooltip styles with dark theme

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Justin Edmund 2025-06-25 01:06:17 +01:00
parent ae15e7978c
commit fb01527469
4 changed files with 350 additions and 55 deletions

View file

@ -0,0 +1,52 @@
import tippy, { type Props as TippyProps, type Instance } from 'tippy.js'
export interface TooltipOptions extends Partial<TippyProps> {
content: string
enabled?: boolean
}
export function tooltip(element: HTMLElement, options: TooltipOptions | string) {
let instance: Instance | undefined
function createTooltip(opts: TooltipOptions | string) {
// Normalize options
const config: TooltipOptions = typeof opts === 'string' ? { content: opts } : opts
// Skip if disabled
if (config.enabled === false) return
// Create tippy instance with sensible defaults
instance = tippy(element, {
content: config.content,
placement: config.placement || 'top',
arrow: config.arrow !== false,
animation: config.animation || 'scale',
theme: config.theme || 'link-tooltip',
delay: config.delay || [200, 0],
duration: config.duration || [200, 150],
offset: config.offset || [0, 10],
...config
})
}
// Initialize tooltip
createTooltip(options)
return {
update(newOptions: TooltipOptions | string) {
// Destroy existing instance
if (instance) {
instance.destroy()
instance = undefined
}
// Create new instance with updated options
createTooltip(newOptions)
},
destroy() {
if (instance) {
instance.destroy()
}
}
}
}

View file

@ -1,5 +1,8 @@
/* Base TipTap Editor Styles with Light/Dark Theme Support */ /* Base TipTap Editor Styles with Light/Dark Theme Support */
/* Import Tippy.js animation styles */
@import 'tippy.js/animations/scale.css';
.tiptap :first-child { .tiptap :first-child {
margin-top: 0; margin-top: 0;
} }
@ -80,6 +83,7 @@
font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono'; font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono';
} }
/* List Styling */ /* List Styling */
.tiptap ul li p, .tiptap ul li p,
.tiptap ol li p { .tiptap ol li p {
@ -180,16 +184,34 @@ input[type='checkbox'] {
width: 1.5rem; width: 1.5rem;
height: 1.5rem; height: 1.5rem;
display: flex; display: flex;
padding-right: 0.5rem; flex-direction: row;
flex-direction: column;
justify-content: center;
align-items: center; align-items: center;
cursor: grab; justify-content: center;
opacity: 100; opacity: 100;
transition-property: opacity; transition-property: opacity, background-color;
transition-duration: 200ms; transition-duration: 200ms;
transition-timing-function: cubic-bezier(0.4, 0, 1, 1); transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
color: var(--border-color-hover); color: var(--border-color-hover);
cursor: grab;
padding: 0.25rem;
border-radius: 6px; /* $corner-radius-sm */
}
/* Invisible hover zone to bridge the gap */
.drag-handle::after {
content: '';
position: absolute;
left: 100%;
top: 50%;
transform: translateY(-50%);
width: 12px; /* Slightly larger than the 8px gap */
height: 100%;
pointer-events: auto;
}
.drag-handle:hover {
opacity: 100 !important;
background-color: #e8e8e8; /* $grey-80 */
} }
.drag-handle:active { .drag-handle:active {
@ -201,6 +223,11 @@ input[type='checkbox'] {
pointer-events: none; pointer-events: none;
} }
.drag-handle.hide.menu-open {
opacity: 100;
pointer-events: auto;
}
@media screen and (max-width: 600px) { @media screen and (max-width: 600px) {
.drag-handle { .drag-handle {
display: none; display: none;

View file

@ -2,10 +2,11 @@
import { type Editor } from '@tiptap/core' import { type Editor } from '@tiptap/core'
import { BubbleMenu } from 'svelte-tiptap' import { BubbleMenu } from 'svelte-tiptap'
import type { ShouldShowProps } from '../../utils.js' import type { ShouldShowProps } from '../../utils.js'
import Copy from 'lucide-svelte/icons/copy'
import Trash from 'lucide-svelte/icons/trash' import Trash from 'lucide-svelte/icons/trash'
import Edit from 'lucide-svelte/icons/pen' import Edit from 'lucide-svelte/icons/pen'
import Check from 'lucide-svelte/icons/check' import Check from 'lucide-svelte/icons/check'
import ExternalLink from 'lucide-svelte/icons/external-link'
import { tooltip } from '$lib/actions/tooltip'
interface Props { interface Props {
editor: Editor editor: Editor
@ -13,9 +14,17 @@
let { editor }: Props = $props() let { editor }: Props = $props()
const link = $derived.by(() => editor.getAttributes('link').href) let link = $state('')
let isEditing = $state(false) let isEditing = $state(false)
let isCopied = $state(false)
// Update link when editor selection changes
$effect(() => {
if (editor && editor.isActive('link')) {
const attrs = editor.getAttributes('link')
link = attrs.href || ''
}
})
function setLink(url: string) { function setLink(url: string) {
if (url.trim() === '') { if (url.trim() === '') {
@ -52,6 +61,9 @@
shouldShow={(props: ShouldShowProps) => { shouldShow={(props: ShouldShowProps) => {
if (!props.editor.isEditable) return false if (!props.editor.isEditable) return false
if (props.editor.isActive('link')) { if (props.editor.isActive('link')) {
// Update link state when bubble menu is shown
const attrs = props.editor.getAttributes('link')
link = attrs.href || ''
return true return true
} else { } else {
isEditing = false isEditing = false
@ -60,61 +72,237 @@
return false return false
} }
}} }}
class="bubble-menu-wrapper" class="bubble-menu-wrapper link-bubble-menu"
tippyOptions={{
animation: 'scale',
duration: [200, 150],
inertia: true
}}
> >
{#if isEditing} {#if isEditing}
<input <div class="link-edit-container">
type="text" <input
bind:value={linkInput} type="text"
placeholder="Enter the URL" bind:value={linkInput}
disabled={!isEditing} placeholder="Enter the URL"
class:valid={isLinkValid} disabled={!isEditing}
class:invalid={!isLinkValid} class:valid={isLinkValid}
/> class:invalid={!isLinkValid}
/>
<button
class="edra-command-button"
onclick={() => {
isEditing = false
editor.commands.focus()
setLink(linkInput)
}}
disabled={!isLinkValid}
>
<Check class="edra-toolbar-icon" />
</button>
</div>
{:else} {:else}
<a href={link} target="_blank">{link}</a>
{/if}
{#if !isEditing}
<button <button
class="edra-command-button" class="link-url-display"
onclick={() => { class:copied={isCopied}
linkInput = link
isEditing = true
}}
title="Edit the URL"
>
<Edit class="edra-toolbar-icon" />
</button>
<button
class="edra-command-button"
onclick={() => { onclick={() => {
navigator.clipboard.writeText(link) navigator.clipboard.writeText(link)
isCopied = true
// Reset after animation
setTimeout(() => {
isCopied = false
}, 800)
}} }}
title="Copy the URL to the clipboard" use:tooltip={'Click to copy URL'}
> >
<Copy class="edra-toolbar-icon" /> {link}
</button>
<button
class="edra-command-button"
onclick={() => {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
}}
title="Remove the link"
>
<Trash class="edra-toolbar-icon" />
</button>
{:else}
<button
class="edra-command-button"
onclick={() => {
isEditing = false
editor.commands.focus()
setLink(linkInput)
}}
disabled={!isLinkValid}
>
<Check class="edra-toolbar-icon" />
</button> </button>
<div class="link-actions">
<button
class="edra-command-button"
onclick={() => {
linkInput = link
isEditing = true
}}
use:tooltip={'Edit URL'}
>
<Edit class="edra-toolbar-icon" />
</button>
<button
class="edra-command-button"
onclick={() => {
let url = link
// Ensure the URL has a protocol
if (!/^https?:\/\//i.test(url)) {
url = 'https://' + url
}
window.open(url, '_blank', 'noopener,noreferrer')
}}
use:tooltip={'Open in new tab'}
>
<ExternalLink class="edra-toolbar-icon" />
</button>
<button
class="edra-command-button danger"
onclick={() => {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
}}
use:tooltip={'Remove link'}
>
<Trash class="edra-toolbar-icon" />
</button>
</div>
{/if} {/if}
</BubbleMenu> </BubbleMenu>
<style lang="scss">
@import '$styles/variables.scss';
@import '../../tooltip.scss';
.link-bubble-menu {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.75rem;
background: white;
border: 1px solid $grey-85;
border-radius: $unit;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
min-width: 280px;
vertical-align: middle;
align-items: center;
}
.link-url-display {
font-size: 0.8125rem;
color: $grey-40;
min-width: 120px;
max-width: 320px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding: 0.375rem 0.5rem;
background: $grey-80;
border-radius: $unit-half;
font-family: $font-stack;
min-height: 32px;
line-height: 1.4;
border: none;
cursor: pointer;
text-align: left;
width: 100%;
transition: background-color 0.2s ease;
&:hover {
background: $grey-85;
}
&.copied {
animation: copy-flash 0.8s ease-out;
}
}
@keyframes copy-flash {
0% {
background-color: #4ade80; // Start with green
color: white;
}
100% {
background-color: $grey-80; // Decay to original gray
color: $grey-40;
}
}
.link-actions {
display: flex;
gap: 0.375rem;
align-items: center;
justify-content: center;
padding: 0.125rem 0;
:global(.edra-command-button) {
background: none;
}
:global(.edra-command-button svg) {
stroke: $grey-30;
}
:global(.edra-command-button:hover svg) {
stroke: $grey-10;
}
:global(.edra-command-button.danger:hover svg) {
stroke: $red-60;
}
}
.link-edit-container {
display: flex;
gap: 0.375rem;
align-items: center;
width: 100%;
animation: fade-in 0.15s ease-out;
}
@keyframes fade-in {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.link-edit-container input {
flex: 1;
min-width: 220px;
padding: 0.3125rem 0.75rem;
border: 1px solid $grey-85;
border-radius: $unit-half;
font-size: 0.875rem;
font-family: $font-stack;
outline: none;
background: white;
transition: border-color 0.2s ease;
height: 32px;
box-sizing: border-box;
}
.link-edit-container input:focus {
border-color: $blue-50;
box-shadow: 0 0 0 3px rgba($blue-50, 0.1);
}
.link-edit-container input.invalid {
border-color: $red-60;
background-color: rgba($red-60, 0.05);
}
:global(.link-bubble-menu .edra-command-button) {
padding: 0.375rem;
border-radius: $unit-half;
transition: all 0.2s ease;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background-color: $grey-80;
}
&.danger {
color: $red-60;
&:hover {
background-color: rgba($red-60, 0.1);
}
}
}
:global(.link-bubble-menu .edra-toolbar-icon) {
width: 18px;
height: 18px;
}
</style>

View file

@ -0,0 +1,28 @@
// Import Tippy.js base styles
@import 'tippy.js/dist/tippy.css';
@import 'tippy.js/animations/scale.css';
// Link tooltip styles - only apply to tooltips with the link-tooltip theme
:global(.tippy-box[data-theme~='link-tooltip']) {
background-color: $grey-00;
color: $grey-100;
font-size: $font-size-extra-small;
font-family: $font-stack;
font-weight: $font-weight;
letter-spacing: $letter-spacing;
line-height: 1.4;
padding: $unit-half $unit;
border-radius: $corner-radius-sm;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
z-index: 9999;
}
:global(.tippy-box[data-theme~='link-tooltip'] .tippy-arrow) {
color: $grey-00;
}
// Animation adjustments for link tooltips
:global(.tippy-box[data-theme~='link-tooltip'][data-animation='scale'][data-state='hidden']) {
opacity: 0;
transform: scale(0.5);
}