Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/assets/stylesheets/lexxy-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,16 @@
}


/* Link selection highlight overlay (matches native ::selection height) */
.lexxy-link-selection-overlay {
background-color: #B7D9FF;
mix-blend-mode: multiply;
pointer-events: none;
position: absolute;
z-index: 1;
}


/* --------------------------------------------------------------------------
/* Prompt */

Expand Down
147 changes: 143 additions & 4 deletions src/elements/dropdown/link.js
Original file line number Diff line number Diff line change
@@ -1,37 +1,176 @@
import { $getSelection, $isRangeSelection } from "lexical"
import { $getSelection, $isRangeSelection, $setSelection } from "lexical"
import { $isLinkNode } from "@lexical/link"
import { ToolbarDropdown } from "../toolbar_dropdown"

export class LinkDropdown extends ToolbarDropdown {
_savedSelectionRect = null
_savedSelectionRects = null
_savedLexicalSelection = null

connectedCallback() {
super.connectedCallback()
this.input = this.querySelector("input")
if (!this.input) return

this.#registerHandlers()
}

#registerHandlers() {
this.container.addEventListener("toggle", this.#handleToggle.bind(this))
this.container?.addEventListener("toggle", this.#handleToggle.bind(this))
this.addEventListener("submit", this.#handleSubmit.bind(this))
this.querySelector("[value='unlink']").addEventListener("click", this.#handleUnlink.bind(this))
this.input.addEventListener("keydown", this.#handleInputKeydown.bind(this))
this.querySelector("[value='unlink']")?.addEventListener("click", this.#handleUnlink.bind(this))

// Save the selection before the details element steals focus
this.container?.querySelector("summary")?.addEventListener("pointerdown", () => this.#saveSelection())
this.editorElement?.addEventListener("keydown", (event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
this.#saveSelection()
}
})
}

#handleToggle({ newState }) {
this.input.value = this.#selectedLinkUrl
this.input.required = newState === "open"

if (newState === "open") {
this.container.setAttribute("data-pinned", "")
this.#showSelectionHighlight()
this.#positionNearSelection()
requestAnimationFrame(() => this.input.focus())
} else {
this.container.removeAttribute("data-pinned")
this.#clearSelectionHighlight()
}
}

#handleInputKeydown(event) {
if (event.key === "Enter") {
event.preventDefault()
event.stopPropagation()
this.querySelector("button[value='link']").click()
}
}

#handleSubmit(event) {
const command = event.submitter?.value
this.editor.dispatchCommand(command, this.input.value)
const url = this.#normalizeUrl(this.input.value)
if (!url) return

this.#clearSelectionHighlight()

// Restore the Lexical selection so the link wraps the correct text
if (this._savedLexicalSelection) {
this.editor.update(() => {
$setSelection(this._savedLexicalSelection)
})
}

this.editor.dispatchCommand(command, url)
this.close()
}

#normalizeUrl(value) {
const trimmed = value.trim()
if (!trimmed) return null

if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed
if (trimmed.includes(".")) return `https://${trimmed}`

return null
}

#handleUnlink() {
this.#clearSelectionHighlight()
this.editor.dispatchCommand("unlink")
this.close()
}

#saveSelection() {
// Pin the dropdown immediately — before the details opens — so the
// toolbar's update-listener closeDropdowns() won't close it mid-open
this.container.setAttribute("data-pinned", "")

const selection = window.getSelection()
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0)
this._savedSelectionRect = range.getBoundingClientRect()

// Compute line-height to expand rects to full selection height
const container = range.commonAncestorContainer
const element = container.nodeType === Node.TEXT_NODE ? container.parentElement : container
const lineHeight = parseFloat(window.getComputedStyle(element).lineHeight) || 0

// Save rects now — after Lexical reconciles the DOM, the range nodes
// may be replaced and getClientRects() would return empty.
// Expand each rect vertically to match the full line-height.
this._savedSelectionRects = Array.from(range.getClientRects()).map(r => {
const expand = lineHeight > r.height ? (lineHeight - r.height) / 2 : 0
return { left: r.left, top: r.top - expand, width: r.width, height: r.height + expand * 2 }
})
}
this.editor.getEditorState().read(() => {
const sel = $getSelection()
if ($isRangeSelection(sel)) {
this._savedLexicalSelection = sel.clone()
}
})
}

#showSelectionHighlight() {
if (!this._savedSelectionRects?.length) return

this._highlightOverlays = []
const editorEl = this.editorElement
const editorRect = editorEl.getBoundingClientRect()

for (const rect of this._savedSelectionRects) {
if (rect.width === 0) continue

const overlay = document.createElement("div")
overlay.className = "lexxy-link-selection-overlay"
overlay.style.left = `${rect.left - editorRect.left - editorEl.clientLeft}px`
overlay.style.top = `${rect.top - editorRect.top - editorEl.clientTop + editorEl.scrollTop}px`
overlay.style.width = `${rect.width}px`
overlay.style.height = `${rect.height}px`
editorEl.appendChild(overlay)
this._highlightOverlays.push(overlay)
}
}

#clearSelectionHighlight() {
if (this._highlightOverlays) {
this._highlightOverlays.forEach(el => el.remove())
this._highlightOverlays = null
}
}

#positionNearSelection() {
const rect = this._savedSelectionRect
if (!rect || (rect.width === 0 && rect.height === 0)) return

this.style.position = "fixed"
this.style.insetInlineEnd = "auto"

requestAnimationFrame(() => {
const popRect = this.getBoundingClientRect()
let top = rect.top - popRect.height - 6
let left = rect.left

if (top < 8) {
top = rect.bottom + 6
}

if (left + popRect.width > window.innerWidth - 8) {
left = Math.max(8, window.innerWidth - popRect.width - 8)
}

this.style.insetBlockStart = `${top}px`
this.style.insetInlineStart = `${left}px`
})
}

get #selectedLinkUrl() {
let url = ""

Expand Down
6 changes: 4 additions & 2 deletions src/elements/toolbar.js
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,9 @@ export class LexicalToolbarElement extends HTMLElement {

#closeDropdowns() {
this.#dropdowns.forEach((details) => {
details.open = false
if (!details.hasAttribute("data-pinned")) {
details.open = false
}
})
}

Expand Down Expand Up @@ -398,7 +400,7 @@ export class LexicalToolbarElement extends HTMLElement {
</summary>
<lexxy-link-dropdown class="lexxy-editor__toolbar-dropdown-content">
<form method="dialog">
<input type="url" placeholder="Enter a URL…" class="input">
<input type="text" placeholder="Enter a URL…" class="input">
<div class="lexxy-editor__toolbar-dropdown-actions">
<button type="submit" class="lexxy-editor__toolbar-button" value="link">Link</button>
<button type="button" class="lexxy-editor__toolbar-button" value="unlink">Unlink</button>
Expand Down
2 changes: 1 addition & 1 deletion test/browser/tests/formatting/block_formatting.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ test.describe("Block formatting", () => {
details.dispatchEvent(new Event("toggle"))
})

const input = page.locator("lexxy-link-dropdown input[type='url']").first()
const input = page.locator("lexxy-link-dropdown input[type='text']").first()
await expect(input).toBeVisible({ timeout: 2_000 })
await input.fill("https://37signals.com")
await page
Expand Down
Loading