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 */
|
||||
|
||||
/* Import Tippy.js animation styles */
|
||||
@import 'tippy.js/animations/scale.css';
|
||||
|
||||
.tiptap :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
|
@ -80,6 +83,7 @@
|
|||
font-family: 'JetBrains Mono', monospace, Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono';
|
||||
}
|
||||
|
||||
|
||||
/* List Styling */
|
||||
.tiptap ul li p,
|
||||
.tiptap ol li p {
|
||||
|
|
@ -180,16 +184,34 @@ input[type='checkbox'] {
|
|||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
display: flex;
|
||||
padding-right: 0.5rem;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
cursor: grab;
|
||||
justify-content: center;
|
||||
opacity: 100;
|
||||
transition-property: opacity;
|
||||
transition-property: opacity, background-color;
|
||||
transition-duration: 200ms;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
|
||||
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 {
|
||||
|
|
@ -201,6 +223,11 @@ input[type='checkbox'] {
|
|||
pointer-events: none;
|
||||
}
|
||||
|
||||
.drag-handle.hide.menu-open {
|
||||
opacity: 100;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 600px) {
|
||||
.drag-handle {
|
||||
display: none;
|
||||
|
|
|
|||
|
|
@ -2,10 +2,11 @@
|
|||
import { type Editor } from '@tiptap/core'
|
||||
import { BubbleMenu } from 'svelte-tiptap'
|
||||
import type { ShouldShowProps } from '../../utils.js'
|
||||
import Copy from 'lucide-svelte/icons/copy'
|
||||
import Trash from 'lucide-svelte/icons/trash'
|
||||
import Edit from 'lucide-svelte/icons/pen'
|
||||
import Check from 'lucide-svelte/icons/check'
|
||||
import ExternalLink from 'lucide-svelte/icons/external-link'
|
||||
import { tooltip } from '$lib/actions/tooltip'
|
||||
|
||||
interface Props {
|
||||
editor: Editor
|
||||
|
|
@ -13,9 +14,17 @@
|
|||
|
||||
let { editor }: Props = $props()
|
||||
|
||||
const link = $derived.by(() => editor.getAttributes('link').href)
|
||||
|
||||
let link = $state('')
|
||||
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) {
|
||||
if (url.trim() === '') {
|
||||
|
|
@ -52,6 +61,9 @@
|
|||
shouldShow={(props: ShouldShowProps) => {
|
||||
if (!props.editor.isEditable) return false
|
||||
if (props.editor.isActive('link')) {
|
||||
// Update link state when bubble menu is shown
|
||||
const attrs = props.editor.getAttributes('link')
|
||||
link = attrs.href || ''
|
||||
return true
|
||||
} else {
|
||||
isEditing = false
|
||||
|
|
@ -60,61 +72,237 @@
|
|||
return false
|
||||
}
|
||||
}}
|
||||
class="bubble-menu-wrapper"
|
||||
class="bubble-menu-wrapper link-bubble-menu"
|
||||
tippyOptions={{
|
||||
animation: 'scale',
|
||||
duration: [200, 150],
|
||||
inertia: true
|
||||
}}
|
||||
>
|
||||
{#if isEditing}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={linkInput}
|
||||
placeholder="Enter the URL"
|
||||
disabled={!isEditing}
|
||||
class:valid={isLinkValid}
|
||||
class:invalid={!isLinkValid}
|
||||
/>
|
||||
<div class="link-edit-container">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={linkInput}
|
||||
placeholder="Enter the URL"
|
||||
disabled={!isEditing}
|
||||
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}
|
||||
<a href={link} target="_blank">{link}</a>
|
||||
{/if}
|
||||
|
||||
{#if !isEditing}
|
||||
<button
|
||||
class="edra-command-button"
|
||||
onclick={() => {
|
||||
linkInput = link
|
||||
isEditing = true
|
||||
}}
|
||||
title="Edit the URL"
|
||||
>
|
||||
<Edit class="edra-toolbar-icon" />
|
||||
</button>
|
||||
<button
|
||||
class="edra-command-button"
|
||||
class="link-url-display"
|
||||
class:copied={isCopied}
|
||||
onclick={() => {
|
||||
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" />
|
||||
</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" />
|
||||
{link}
|
||||
</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}
|
||||
</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