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:
parent
ae15e7978c
commit
fb01527469
4 changed files with 350 additions and 55 deletions
52
src/lib/actions/tooltip.ts
Normal file
52
src/lib/actions/tooltip.ts
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
28
src/lib/components/edra/tooltip.scss
Normal file
28
src/lib/components/edra/tooltip.scss
Normal 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);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue