diff --git a/app/assets/stylesheets/lexxy-editor.css b/app/assets/stylesheets/lexxy-editor.css index b075e30fb..58cee04de 100644 --- a/app/assets/stylesheets/lexxy-editor.css +++ b/app/assets/stylesheets/lexxy-editor.css @@ -512,25 +512,27 @@ :where(.lexxy-editor__toolbar-dropdown) { user-select: none; -webkit-user-select: none; +} - &.lexxy-editor__toolbar-dropdown--chevron { - summary { - aspect-ratio: unset; - gap: 0.5ch; - grid-template-columns: 2fr 1fr; - padding-inline: 0.75ch; +/* Chevron variant needs class specificity to override .lexxy-editor__toolbar-button's aspect-ratio */ +.lexxy-editor__toolbar-dropdown--chevron summary.lexxy-editor__toolbar-button { + aspect-ratio: unset; + gap: 0.5ch; + grid-template-columns: 2fr 1fr; + padding-inline: 0.75ch; +} - &:after { - block-size: 0.3ch; - border-block-end: 2px solid currentcolor; - border-inline-end: 2px solid currentcolor; - content: ""; - display: inline-block; - inline-size: 0.3ch; - transform: rotate(45deg); - } - } - } +.lexxy-editor__toolbar-dropdown--chevron summary.lexxy-editor__toolbar-button:after { + block-size: 0.3ch; + border-block-end: 2px solid currentcolor; + border-inline-end: 2px solid currentcolor; + content: ""; + display: inline-block; + inline-size: 0.3ch; + transform: rotate(45deg); +} + +:where(.lexxy-editor__toolbar-dropdown) { summary ~ * { background-color: var(--lexxy-color-canvas); @@ -1001,8 +1003,9 @@ --lexxy-prompt-offset-y: 0; background-color: var(--lexxy-color-canvas); + border: 1px solid var(--lexxy-color-ink-lightest, #e5e7eb); border-radius: calc(var(--lexxy-prompt-padding) * 2); - box-shadow: var(--lexxy-shadow); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08); color: var(--lexxy-color-ink); font-family: var(--lexxy-font-base); font-size: var(--lexxy-text-small); @@ -1010,24 +1013,42 @@ inset-block-start: var(--lexxy-prompt-offset-y); inset-inline-start: var(--lexxy-prompt-offset-x); margin: 0; - max-block-size: 200px; - max-inline-size: min(20ch, calc(100% - var(--lexxy-prompt-offset-x))); + max-block-size: 40vh; + max-inline-size: 300px; min-inline-size: 20ch; overflow: auto; - padding: var(--lexxy-prompt-padding); - position: absolute; + padding: 6px var(--lexxy-prompt-padding); + position: fixed; visibility: hidden; z-index: var(--lexxy-z-popup); - &[data-clipped-at-right] { - inset-inline-start: unset; - inset-inline-end: 1ch; - } +} - &[data-clipped-at-bottom] { - inset-block-start: unset; - inset-block-end: var(--lexxy-prompt-offset-y); - } +/* Override browser default ul padding-inline-start: 40px which beats :where() specificity */ +.lexxy-prompt-menu { + padding: 2px var(--lexxy-prompt-padding); + min-inline-size: 300px; + scroll-padding: 6px 0 32px; + position: fixed; +} + +.lexxy-prompt-menu::before { + content: ""; + position: sticky; + top: -2px; + display: block; + height: 24px; + margin-bottom: -24px; + background: linear-gradient(to bottom, var(--lexxy-color-canvas, #fff), transparent); + z-index: 1; + pointer-events: none; + border-radius: inherit; + opacity: 0; + transition: opacity 150ms; +} + +.lexxy-prompt-menu--fade-top::before { + opacity: 1; } :where(.lexxy-prompt-menu--visible) { @@ -1047,7 +1068,17 @@ } &[aria-selected] { - background-color: var(--lexxy-color-selected); + background-color: var(--lexxy-color-ink-lightest); + } + + &[data-keyboard-focus] { + outline: 2px solid var(--lexxy-color-accent, #2563eb); + outline-offset: -2px; + background-color: var(--lexxy-color-ink-lightest); + } + + .lexxy-prompt-menu--keyboard-active &:not([data-keyboard-focus]):hover { + background-color: transparent; } img { @@ -1068,6 +1099,112 @@ padding: var(--lexxy-prompt-padding); } +/* Slash command items */ + +:where(.lexxy-slash-command__icon) { + align-items: center; + color: var(--lexxy-color-ink-medium); + display: flex; + flex-shrink: 0; + + svg { + block-size: 1.125em; + fill: currentColor; + inline-size: 1.125em; + } +} + +:where(.lexxy-slash-command__label) { + white-space: nowrap; + flex: 1; +} + +:where(.lexxy-slash-command__shortcut) { + color: var(--lexxy-color-ink-lighter, #9ca3af); + font-size: 12px; + font-family: var(--lexxy-font-mono, ui-monospace, monospace); + margin-left: auto; + flex-shrink: 0; +} + +:where(.lexxy-slash-command__color-swatch) { + width: 18px; + height: 18px; + border-radius: 3px; + border: 1px solid var(--lexxy-color-ink-lightest, #e5e7eb); + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 11px; + font-weight: 700; + flex-shrink: 0; +} + +/* Section headers in prompt menus */ + +:where(.lexxy-prompt-menu__section-header) { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + color: var(--lexxy-color-ink-lighter, #9ca3af); + padding: 8px var(--lexxy-prompt-padding) 2px; + letter-spacing: 0.05em; + pointer-events: none; + user-select: none; +} + +:where(.lexxy-prompt-menu__section-header:first-child) { + padding-top: 2px; +} + +/* Close menu footer */ + +:where(.lexxy-prompt-menu__footer) { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px var(--lexxy-prompt-padding); + margin-top: 6px; + color: var(--lexxy-color-ink-lighter, #9ca3af); + font-size: var(--lexxy-text-small); + border-top: 1px solid var(--lexxy-color-ink-lightest, #e5e7eb); + margin-top: 4px; + cursor: pointer; + position: sticky; + bottom: -2px; + background: var(--lexxy-color-canvas, #fff); + z-index: 1; +} + +.lexxy-prompt-menu__footer::before { + content: ""; + position: absolute; + bottom: 100%; + left: 0; + right: 0; + height: 34px; + background: linear-gradient(to top, var(--lexxy-color-canvas, #fff), transparent); + pointer-events: none; + opacity: 0; + transition: opacity 150ms; +} + +.lexxy-prompt-menu--fade-bottom .lexxy-prompt-menu__footer::before { + opacity: 1; +} + + +:where(.lexxy-prompt-menu__footer:hover) { + color: var(--lexxy-color-ink); +} + +:where(.lexxy-prompt-menu__footer-key) { + background: var(--lexxy-color-ink-lightest, #e5e7eb); + border-radius: 3px; + padding: 1px 5px; + font-size: 10px; +} + /* -------------------------------------------------------------------------- /* Custom attachments */ diff --git a/app/assets/stylesheets/lexxy-variables.css b/app/assets/stylesheets/lexxy-variables.css index e9d93ced0..49a02a4d2 100644 --- a/app/assets/stylesheets/lexxy-variables.css +++ b/app/assets/stylesheets/lexxy-variables.css @@ -83,5 +83,5 @@ --lexxy-toolbar-button-size: 2lh; --lexxy-radius: 0.5ch; --lexxy-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); - --lexxy-z-popup: 1000; + --lexxy-z-popup: 19999; } \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js index 66384ac47..d79494c95 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -42,7 +42,14 @@ export default [ customElements: "readonly", Prism: "readonly", ResizeObserver: "readonly", - PointerEvent: "readonly" + PointerEvent: "readonly", + getComputedStyle: "readonly", + localStorage: "readonly", + NodeFilter: "readonly", + queueMicrotask: "readonly", + requestIdleCallback: "readonly", + cancelIdleCallback: "readonly", + performance: "readonly" } }, rules: { diff --git a/package.json b/package.json index 42b79f86a..8b5626060 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@lexical/table": "^0.41.0", "@lexical/utils": "^0.41.0", "dompurify": "^3.3.0", + "fuzzysort": "^3.1.0", "lexical": "^0.41.0", "marked": "^16.4.1", "prismjs": "^1.30.0" diff --git a/src/editor/extensions.js b/src/editor/extensions.js index 25f1d71bb..5222e942f 100644 --- a/src/editor/extensions.js +++ b/src/editor/extensions.js @@ -12,6 +12,10 @@ export default class Extensions { return this.enabledExtensions.map(ext => ext.lexicalExtension).filter(Boolean) } + initializeEditors() { + this.enabledExtensions.forEach(ext => ext.initializeEditor?.()) + } + initializeToolbars() { if (this.#lexxyToolbar) { this.enabledExtensions.forEach(ext => ext.initializeToolbar(this.#lexxyToolbar)) diff --git a/src/editor/markdown/horizontal_rule_transformer.js b/src/editor/markdown/horizontal_rule_transformer.js new file mode 100644 index 000000000..8e937402a --- /dev/null +++ b/src/editor/markdown/horizontal_rule_transformer.js @@ -0,0 +1,82 @@ +import { $createParagraphNode, $createTextNode, $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_CRITICAL, KEY_DOWN_COMMAND } from "lexical" +import { $createCodeNode, $isCodeNode } from "@lexical/code" +import { HorizontalDividerNode } from "../../nodes/horizontal_divider_node" + +// Markdown export transformer for serialization +export const HORIZONTAL_RULE_TRANSFORMER = { + dependencies: [ HorizontalDividerNode ], + export: (node) => { + if (node instanceof HorizontalDividerNode) { + return "---" + } + return null + }, + regExp: /^---$/, + replace: (parentNode) => { + const hr = new HorizontalDividerNode() + parentNode.insertBefore(hr) + parentNode.selectStart() + }, + type: "element", +} + +// Live typing shortcuts that trigger immediately (no trailing space required). +// Intercepts KEY_DOWN at CRITICAL priority to check if the keystroke would +// complete a "---" or "```" pattern, and transforms before Lexical processes it. +export function registerImmediateBlockShortcuts(editor) { + return editor.registerCommand( + KEY_DOWN_COMMAND, + (event) => { + // Only care about single printable characters + if (event.key.length !== 1 || event.metaKey || event.ctrlKey || event.altKey) return false + + const selection = $getSelection() + if (!$isRangeSelection(selection) || !selection.isCollapsed()) return false + + const anchorNode = selection.anchor.getNode() + if (!$isTextNode(anchorNode)) return false + + const parent = anchorNode.getParent() + if (!parent || $isCodeNode(parent)) return false + if (anchorNode !== parent.getFirstChild()) return false + + const text = anchorNode.getTextContent() + const offset = selection.anchor.offset + + // Check what the text would be after this keystroke + const projected = text.slice(0, offset) + event.key + text.slice(offset) + + // --- → horizontal divider (only when it's the sole content and no siblings) + if (projected === "---" && anchorNode.getNextSibling() === null) { + event.preventDefault() + + const hr = new HorizontalDividerNode() + const p = $createParagraphNode() + parent.insertBefore(hr) + parent.replace(p) + p.selectStart() + return true + } + + // ``` → code block (text after cursor position becomes content) + if (event.key === "`" && text.slice(0, offset) === "``" && offset === 2) { + event.preventDefault() + + // Everything after the cursor is the content to put in the code block + const afterBackticks = parent.getTextContent().slice(offset) + + const codeNode = $createCodeNode() + if (afterBackticks.length > 0) { + codeNode.append($createTextNode(afterBackticks)) + } + parent.insertBefore(codeNode) + parent.remove() + codeNode.selectEnd() + return true + } + + return false + }, + COMMAND_PRIORITY_CRITICAL + ) +} diff --git a/src/editor/markdown/quote_alias_transformers.js b/src/editor/markdown/quote_alias_transformers.js new file mode 100644 index 000000000..e9b7aa782 --- /dev/null +++ b/src/editor/markdown/quote_alias_transformers.js @@ -0,0 +1,33 @@ +import { $createQuoteNode, QuoteNode } from "@lexical/rich-text" + +// | at the start of a line followed by a space → blockquote +export const QUOTE_PIPE_TRANSFORMER = { + dependencies: [ QuoteNode ], + export: null, + regExp: /^\|\s/, + replace: (parentNode, children, _match, isImport) => { + const node = $createQuoteNode() + node.append(...children) + parentNode.replace(node) + if (!isImport) { + node.select(0, 0) + } + }, + type: "element", +} + +// " at the start of a line followed by a space → blockquote +export const QUOTE_DOUBLEQUOTE_TRANSFORMER = { + dependencies: [ QuoteNode ], + export: null, + regExp: /^["\u201C\u201D]\s/, + replace: (parentNode, children, _match, isImport) => { + const node = $createQuoteNode() + node.append(...children) + parentNode.replace(node) + if (!isImport) { + node.select(0, 0) + } + }, + type: "element", +} diff --git a/src/editor/prompt/base_source.js b/src/editor/prompt/base_source.js index 8f5b5894e..70ccd8463 100644 --- a/src/editor/prompt/base_source.js +++ b/src/editor/prompt/base_source.js @@ -13,12 +13,26 @@ export default class BaseSource { // Protected - buildListItemElementFor(promptItemElement) { + buildListItemElementFor(promptItemElement, isFiltering = false) { const template = promptItemElement.querySelector("template[type='menu']") const fragment = template.content.cloneNode(true) const listItemElement = createElement("li", { role: "option", id: generateDomId("prompt-item"), tabindex: "0" }) listItemElement.classList.add("lexxy-prompt-menu__item") listItemElement.appendChild(fragment) + + if (isFiltering) { + const filterSuffix = promptItemElement.getAttribute("data-filter-suffix") + if (filterSuffix) { + const label = listItemElement.querySelector(".lexxy-slash-command__label") + if (label) { + const suffixEl = createElement("span") + suffixEl.classList.add("lexxy-slash-command__filter-suffix") + suffixEl.textContent = ` \u00b7 ${filterSuffix}` + label.appendChild(suffixEl) + } + } + } + return listItemElement } diff --git a/src/editor/prompt/local_filter_source.js b/src/editor/prompt/local_filter_source.js index 084dc5f24..6df747309 100644 --- a/src/editor/prompt/local_filter_source.js +++ b/src/editor/prompt/local_filter_source.js @@ -1,5 +1,6 @@ import BaseSource from "./base_source" -import { filterMatches } from "../../helpers/string_helper" +import fuzzysort from "fuzzysort" +import { createElement } from "../../helpers/html_helper" export default class LocalFilterSource extends BaseSource { async buildListItems(filter = "") { @@ -17,18 +18,69 @@ export default class LocalFilterSource extends BaseSource { } #buildListItemsFromPromptItems(promptItems, filter) { - const listItems = [] this.promptItemByListItem = new WeakMap() - promptItems.forEach((promptItem) => { - const searchableText = promptItem.getAttribute("search") - if (!filter || filterMatches(searchableText, filter)) { - const listItem = this.buildListItemElementFor(promptItem) + if (filter.length > 0) { + return this.#buildFilteredResults(promptItems, filter) + } else { + return this.#buildSectionedResults(promptItems) + } + } + + #buildFilteredResults(promptItems, filter) { + const listItems = [] + const targets = promptItems.map(promptItem => ({ + promptItem, + search: promptItem.getAttribute("search") + })) + + const results = fuzzysort.go(filter, targets, { key: "search" }) + + if (results.length > 0) { + listItems.push(this.#buildSectionHeader("Filtered results")) + for (const result of results) { + const listItem = this.buildListItemElementFor(result.obj.promptItem, true) + this.promptItemByListItem.set(listItem, result.obj.promptItem) + listItems.push(listItem) + } + } + + return listItems + } + + #buildSectionedResults(promptItems) { + const listItems = [] + const sections = [] + const sectionMap = new Map() + + for (const promptItem of promptItems) { + const section = promptItem.getAttribute("data-section") || "" + if (!sectionMap.has(section)) { + const group = { name: section, items: [] } + sectionMap.set(section, group) + sections.push(group) + } + sectionMap.get(section).items.push(promptItem) + } + + for (const { name, items } of sections) { + if (name) { + listItems.push(this.#buildSectionHeader(name)) + } + for (const promptItem of items) { + const listItem = this.buildListItemElementFor(promptItem, false) this.promptItemByListItem.set(listItem, promptItem) listItems.push(listItem) } - }) + } return listItems } + + #buildSectionHeader(name) { + const header = createElement("li", { role: "presentation" }) + header.classList.add("lexxy-prompt-menu__section-header") + header.textContent = name + return header + } } diff --git a/src/elements/editor.js b/src/elements/editor.js index 7fa0df19f..f74eca444 100644 --- a/src/elements/editor.js +++ b/src/elements/editor.js @@ -6,8 +6,12 @@ import { registerPlainText } from "@lexical/plain-text" import { HeadingNode, QuoteNode, registerRichText } from "@lexical/rich-text" import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html" import { CodeHighlightNode, CodeNode, registerCodeHighlighting } from "@lexical/code" -import { TRANSFORMERS, registerMarkdownShortcuts } from "@lexical/markdown" +import { TRANSFORMERS as LEXICAL_TRANSFORMERS, registerMarkdownShortcuts } from "@lexical/markdown" import { registerMarkdownLeadingTagHandler } from "../editor/markdown/leading_tag_handler" +import { HORIZONTAL_RULE_TRANSFORMER, registerImmediateBlockShortcuts } from "../editor/markdown/horizontal_rule_transformer" +import { QUOTE_DOUBLEQUOTE_TRANSFORMER, QUOTE_PIPE_TRANSFORMER } from "../editor/markdown/quote_alias_transformers" + +const TRANSFORMERS = [ ...LEXICAL_TRANSFORMERS, HORIZONTAL_RULE_TRANSFORMER, QUOTE_PIPE_TRANSFORMER, QUOTE_DOUBLEQUOTE_TRANSFORMER ] import { createEmptyHistoryState, registerHistory } from "@lexical/history" import theme from "../config/theme" @@ -31,6 +35,7 @@ import { TrixContentExtension } from "../extensions/trix_content_extension" import { TablesExtension } from "../extensions/tables_extension" import { AttachmentsExtension } from "../extensions/attachments_extension.js" import { FormatEscapeExtension } from "../extensions/format_escape_extension.js" +import { SlashCommandsExtension } from "../extensions/slash_commands_extension.js" export class LexicalEditorElement extends HTMLElement { @@ -124,7 +129,8 @@ export class LexicalEditorElement extends HTMLElement { TrixContentExtension, TablesExtension, AttachmentsExtension, - FormatEscapeExtension + FormatEscapeExtension, + SlashCommandsExtension ] } @@ -243,6 +249,7 @@ export class LexicalEditorElement extends HTMLElement { this.#registerFocusEvents() this.#attachDebugHooks() this.#attachToolbar() + this.extensions.initializeEditors() this.#loadInitialValue() this.#resetBeforeTurboCaches() } @@ -380,6 +387,7 @@ export class LexicalEditorElement extends HTMLElement { this.#registerTableComponents() this.#registerCodeHiglightingComponents() if (this.supportsMarkdown) { + registerImmediateBlockShortcuts(this.editor) registerMarkdownShortcuts(this.editor, TRANSFORMERS) registerMarkdownLeadingTagHandler(this.editor, TRANSFORMERS) } diff --git a/src/elements/prompt.js b/src/elements/prompt.js index de6a4474d..e8d86bdf0 100644 --- a/src/elements/prompt.js +++ b/src/elements/prompt.js @@ -1,7 +1,7 @@ import Lexxy from "../config/lexxy" import { createElement, generateDomId, parseHtml } from "../helpers/html_helper" import { getNonce } from "../helpers/csp_helper" -import { $createTextNode, $getSelection, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_CRITICAL, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, KEY_SPACE_COMMAND, KEY_TAB_COMMAND } from "lexical" +import { $createParagraphNode, $createTextNode, $getSelection, $isElementNode, $isRangeSelection, $isTextNode, COMMAND_PRIORITY_CRITICAL, KEY_ARROW_DOWN_COMMAND, KEY_ARROW_UP_COMMAND, KEY_ENTER_COMMAND, KEY_SPACE_COMMAND, KEY_TAB_COMMAND } from "lexical" import { CustomActionTextAttachmentNode } from "../nodes/custom_action_text_attachment_node" import InlinePromptSource from "../editor/prompt/inline_source" import DeferredPromptSource from "../editor/prompt/deferred_source" @@ -16,7 +16,9 @@ export class LexicalPromptElement extends HTMLElement { super() this.keyListeners = [] this.showPopoverId = 0 + this.#keyboardFocusTimer = null } + #keyboardFocusTimer = null static observedAttributes = [ "connected" ] @@ -217,10 +219,19 @@ export class LexicalPromptElement extends HTMLElement { return Array.from(this.popoverElement.querySelectorAll(".lexxy-prompt-menu__item")) } - #selectOption(listItem) { + #selectOption(listItem, direction) { this.#clearSelection() listItem.toggleAttribute("aria-selected", true) - listItem.scrollIntoView({ block: "nearest", behavior: "smooth" }) + + // Keyboard navigation sets the outline ring and suppresses hover bg + if (direction) { + if (this.#keyboardFocusTimer) clearTimeout(this.#keyboardFocusTimer) + listItem.toggleAttribute("data-keyboard-focus", true) + this.popoverElement.classList.add("lexxy-prompt-menu--keyboard-active") + this.#scrollWithLookahead(listItem, direction) + } else { + listItem.scrollIntoView({ block: "nearest" }) + } listItem.focus() // Preserve selection to prevent cursor jump @@ -234,33 +245,111 @@ export class LexicalPromptElement extends HTMLElement { } #clearSelection() { - this.#listItemElements.forEach((item) => { item.toggleAttribute("aria-selected", false) }) + this.#listItemElements.forEach((item) => { + item.toggleAttribute("aria-selected", false) + item.removeAttribute("data-keyboard-focus") + }) this.#editorContentElement.removeAttribute("aria-controls") this.#editorContentElement.removeAttribute("aria-activedescendant") this.#editorContentElement.removeAttribute("aria-haspopup") } + #scrollWithLookahead(listItem, direction = "down") { + const container = this.popoverElement + const items = this.#listItemElements + const index = items.indexOf(listItem) + const lookahead = 2 + + const padding = 6 + const footer = container.querySelector(".lexxy-prompt-menu__footer") + const footerHeight = footer ? footer.offsetHeight + 8 : 0 + const containerRect = container.getBoundingClientRect() + const visibleTop = containerRect.top + padding + const visibleBottom = containerRect.bottom - footerHeight + + // First ensure the selected item itself is visible + if (index === 0) { + container.scrollTop = 0 + } else { + const itemRect = listItem.getBoundingClientRect() + if (itemRect.top < visibleTop) { + container.scrollTop -= visibleTop - itemRect.top + } else if (itemRect.bottom > visibleBottom) { + container.scrollTop += itemRect.bottom - visibleBottom + } + } + + // Then scroll the lookahead target into view + const targetIndex = direction === "down" + ? Math.min(index + lookahead, items.length - 1) + : Math.max(index - lookahead, 0) + + // When near the top, scroll all the way to reveal section headers + if (direction === "up" && targetIndex <= 1) { + container.scrollTop = 0 + } else if (direction === "down" && targetIndex >= items.length - 2) { + container.scrollTop = container.scrollHeight + } else { + const target = items[targetIndex] + if (target && target !== listItem) { + const targetRect = target.getBoundingClientRect() + if (direction === "down" && targetRect.bottom > visibleBottom) { + container.scrollTop += targetRect.bottom - visibleBottom + } else if (direction === "up" && targetRect.top < visibleTop) { + container.scrollTop -= visibleTop - targetRect.top + } + } + } + + this.#updateScrollFades() + } + + #updateScrollFades() { + const container = this.popoverElement + if (!container) return + + const atTop = container.scrollTop <= 1 + const footer = container.querySelector(".lexxy-prompt-menu__footer") + const footerHeight = footer ? footer.offsetHeight + 4 : 0 + const atBottom = container.scrollTop + container.clientHeight >= container.scrollHeight - footerHeight + + container.classList.toggle("lexxy-prompt-menu--fade-top", !atTop) + container.classList.toggle("lexxy-prompt-menu--fade-bottom", !atBottom) + } + #positionPopover() { const { x, y, fontSize } = this.#selection.cursorPosition - const editorRect = this.#editorElement.getBoundingClientRect() - const contentRect = this.#editorContentElement.getBoundingClientRect() - const verticalOffset = contentRect.top - editorRect.top + const rootRect = this.#editorContentElement.getBoundingClientRect() + + // Convert editor-relative coords to viewport coords for position: fixed + const viewportX = rootRect.left + x + const viewportY = rootRect.top + y if (!this.popoverElement.hasAttribute("data-anchored")) { - this.#setPopoverOffsetX(x) - this.#setPopoverOffsetY(y + verticalOffset) + this.#setPopoverOffsetX(viewportX) + this.#setPopoverOffsetY(viewportY) this.popoverElement.toggleAttribute("data-anchored", true) } const popoverRect = this.popoverElement.getBoundingClientRect() + // Clamp to viewport right edge if (popoverRect.right > window.innerWidth) { - this.popoverElement.toggleAttribute("data-clipped-at-right", true) + this.#setPopoverOffsetX(Math.max(8, window.innerWidth - popoverRect.width - 8)) } + // Flip above cursor if it would overflow viewport bottom + const flippedGap = fontSize * 3 if (popoverRect.bottom > window.innerHeight) { - this.#setPopoverOffsetY(contentRect.height - y + fontSize) - this.popoverElement.toggleAttribute("data-clipped-at-bottom", true) + this.popoverElement.toggleAttribute("data-flipped", true) + this.#setPopoverOffsetY(viewportY - popoverRect.height - flippedGap) + } + + // When flipped above cursor, recalculate top so the bottom edge + // stays anchored to the cursor as the menu height changes (filtering) + if (this.popoverElement.hasAttribute("data-flipped")) { + const flippedTop = viewportY - this.popoverElement.offsetHeight - flippedGap + this.#setPopoverOffsetY(Math.max(8, flippedTop)) } } @@ -276,6 +365,7 @@ export class LexicalPromptElement extends HTMLElement { this.popoverElement.removeAttribute("data-clipped-at-bottom") this.popoverElement.removeAttribute("data-clipped-at-right") this.popoverElement.removeAttribute("data-anchored") + this.popoverElement.removeAttribute("data-flipped") } async #hidePopover() { @@ -340,6 +430,18 @@ export class LexicalPromptElement extends HTMLElement { #showResults(filteredListItems) { this.popoverElement.classList.remove("lexxy-prompt-menu--empty") this.popoverElement.append(...filteredListItems) + if (this.hasAttribute("dispatch-command")) { + this.popoverElement.appendChild(this.#buildFooter()) + } + this.popoverElement.scrollTop = 0 + requestAnimationFrame(() => this.#updateScrollFades()) + } + + #buildFooter() { + const footer = createElement("li", { role: "presentation" }) + footer.classList.add("lexxy-prompt-menu__footer") + footer.innerHTML = "Close menu" + return footer } #showEmptyResults() { @@ -374,12 +476,12 @@ export class LexicalPromptElement extends HTMLElement { #moveSelectionDown() { const nextIndex = this.#selectedIndex + 1 - if (nextIndex < this.#listItemElements.length) this.#selectOption(this.#listItemElements[nextIndex]) + if (nextIndex < this.#listItemElements.length) this.#selectOption(this.#listItemElements[nextIndex], "down") } #moveSelectionUp() { const previousIndex = this.#selectedIndex - 1 - if (previousIndex >= 0) this.#selectOption(this.#listItemElements[previousIndex]) + if (previousIndex >= 0) this.#selectOption(this.#listItemElements[previousIndex], "up") } get #selectedIndex() { @@ -408,13 +510,87 @@ export class LexicalPromptElement extends HTMLElement { if (!promptItem) { return } - const templates = Array.from(promptItem.querySelectorAll("template[type='editor']")) const stringToReplace = `${this.trigger}${this.#editorContents.textBackUntil(this.trigger)}` - if (this.hasAttribute("insert-editable-text")) { - this.#insertTemplatesAsEditableText(templates, stringToReplace) + if (this.hasAttribute("dispatch-command")) { + this.#dispatchCommandFromPromptItem(promptItem, stringToReplace) } else { - this.#insertTemplatesAsAttachments(templates, stringToReplace, promptItem.getAttribute("sgid")) + const templates = Array.from(promptItem.querySelectorAll("template[type='editor']")) + + if (this.hasAttribute("insert-editable-text")) { + this.#insertTemplatesAsEditableText(templates, stringToReplace) + } else { + this.#insertTemplatesAsAttachments(templates, stringToReplace, promptItem.getAttribute("sgid")) + } + } + } + + #dispatchCommandFromPromptItem(promptItem, stringToReplace) { + const command = promptItem.getAttribute("data-command") + if (!command) return + + const payloadStr = promptItem.getAttribute("data-command-payload") + const payload = payloadStr ? JSON.parse(payloadStr) : undefined + const selectBlock = promptItem.hasAttribute("data-command-select-block") + const insertBelow = promptItem.hasAttribute("data-insert-below") + + this.#editor.update(() => { + this.#editorContents.replaceTextBackUntil(stringToReplace, [ $createTextNode("") ]) + }) + + requestAnimationFrame(() => { + this.#editor.update(() => { + this.#removeTrailingWhitespaceNode() + + if (insertBelow) { + this.#insertNewBlockBelow() + } else if (selectBlock) { + const sel = $getSelection() + if ($isRangeSelection(sel)) { + const node = sel.anchor.getNode() + const block = $isElementNode(node) ? node : node.getParentOrThrow() + block.select(0, block.getChildrenSize()) + this.#editor.dispatchCommand(command, payload) + // Collapse selection to end so cursor stays inside the styled text + const afterSel = $getSelection() + if ($isRangeSelection(afterSel)) { + afterSel.anchor.set(afterSel.focus.key, afterSel.focus.offset, afterSel.focus.type) + } + return + } + } + this.#editor.dispatchCommand(command, payload) + }) + }) + } + + #insertNewBlockBelow() { + const selection = $getSelection() + if (!$isRangeSelection(selection)) return + + const anchorNode = selection.anchor.getNode() + const topLevelElement = anchorNode.getTopLevelElementOrThrow() + + // Always insert below when inside a list, or when the block has content + const isListBlock = topLevelElement.getType() === "list" + const blockHasContent = topLevelElement.getTextContent().trim() !== "" + + if (isListBlock || blockHasContent) { + const newParagraph = $createParagraphNode() + topLevelElement.insertAfter(newParagraph) + newParagraph.selectStart() + } + // Otherwise, the command will convert the current empty block in place + } + + #removeTrailingWhitespaceNode() { + const selection = $getSelection() + if (!$isRangeSelection(selection)) return + + const anchorNode = selection.anchor.getNode() + if ($isTextNode(anchorNode) && anchorNode.getTextContent().trim() === "") { + anchorNode.setTextContent("") + anchorNode.select(0, 0) } } @@ -470,15 +646,23 @@ export class LexicalPromptElement extends HTMLElement { async #buildPopover() { const popoverContainer = createElement("ul", { role: "listbox", id: generateDomId("prompt-popover") }) // Avoiding [popover] due to not being able to position at an arbitrary X, Y position. popoverContainer.classList.add("lexxy-prompt-menu") - popoverContainer.style.position = "absolute" + popoverContainer.style.position = "fixed" popoverContainer.setAttribute("nonce", getNonce()) popoverContainer.append(...await this.source.buildListItems()) popoverContainer.addEventListener("click", this.#handlePopoverClick) + popoverContainer.addEventListener("mousemove", this.#handlePopoverMousemove) + popoverContainer.addEventListener("scroll", this.#handlePopoverScroll, { passive: true }) this.#editorElement.appendChild(popoverContainer) return popoverContainer } #handlePopoverClick = (event) => { + if (event.target.closest(".lexxy-prompt-menu__footer")) { + this.#hidePopover() + this.#editorElement.focus() + return + } + const listItem = event.target.closest(".lexxy-prompt-menu__item") if (listItem) { this.#selectOption(listItem) @@ -486,6 +670,31 @@ export class LexicalPromptElement extends HTMLElement { } } + #handlePopoverMousemove = (event) => { + this.popoverElement.classList.remove("lexxy-prompt-menu--keyboard-active") + + const listItem = event.target.closest(".lexxy-prompt-menu__item") + if (!listItem || listItem.hasAttribute("aria-selected")) return + + // Clear keyboard focus outline after a short delay when mouse moves to a different item + if (this.#keyboardFocusTimer) clearTimeout(this.#keyboardFocusTimer) + const currentKeyboardItem = this.popoverElement.querySelector("[data-keyboard-focus]") + if (currentKeyboardItem && currentKeyboardItem !== listItem) { + this.#keyboardFocusTimer = setTimeout(() => { + currentKeyboardItem.removeAttribute("data-keyboard-focus") + }, 500) + } + + // Silently update selection tracking so keyboard continues from here + this.#clearSelection() + listItem.toggleAttribute("aria-selected", true) + this.#editorContentElement.setAttribute("aria-activedescendant", listItem.id) + } + + #handlePopoverScroll = () => { + this.#updateScrollFades() + } + #reconnect() { this.disconnectedCallback() this.connectedCallback() diff --git a/src/extensions/slash_commands_extension.js b/src/extensions/slash_commands_extension.js new file mode 100644 index 000000000..680b98149 --- /dev/null +++ b/src/extensions/slash_commands_extension.js @@ -0,0 +1,164 @@ +import LexxyExtension from "./lexxy_extension" +import { createElement } from "../helpers/html_helper" +import ToolbarIcons from "../elements/toolbar_icons" + +const COLOR_NAMES = [ "Yellow", "Orange", "Red", "Pink", "Purple", "Blue", "Green", "Brown", "Gray" ] + +function colorName(cssVar) { + const match = cssVar.match(/--highlight-(?:bg-)?(\d+)/) + return match ? COLOR_NAMES[parseInt(match[1]) - 1] || `Color ${match[1]}` : cssVar +} + +const CONVERTIBLE_BLOCK_ITEMS = [ + { command: "setFormatParagraph", label: "Text", search: "paragraph normal text plain", icon: ToolbarIcons.paragraph }, + { command: "setFormatHeadingXLarge", label: "Heading 1", search: "heading 1 title h1 xlarge", icon: ToolbarIcons.h1, shortcut: "#" }, + { command: "setFormatHeadingLarge", label: "Heading 2", search: "heading 2 title h2 large", icon: ToolbarIcons.h2, shortcut: "##" }, + { command: "setFormatHeadingMedium", label: "Heading 3", search: "heading 3 title h3 medium", icon: ToolbarIcons.h3, shortcut: "###" }, + { command: "setFormatHeadingSmall", label: "Heading 4", search: "heading 4 title h4 small", icon: ToolbarIcons.h4, shortcut: "####" }, + { command: "insertUnorderedList", label: "Bullet list", search: "bullet list unordered", icon: ToolbarIcons.ul, shortcut: "-" }, + { command: "insertOrderedList", label: "Numbered list", search: "numbered list ordered", icon: ToolbarIcons.ol, shortcut: "1." }, + { command: "insertQuoteBlock", label: "Quote", search: "quote blockquote", icon: ToolbarIcons.quote, shortcut: "> | \"" }, + { command: "insertCodeBlock", label: "Code block", search: "code block pre", icon: ToolbarIcons.code, shortcut: "```" }, +] + +const INSERT_ONLY_ITEMS = [ + { command: "insertTable", label: "Table", search: "table grid", icon: ToolbarIcons.table }, + { command: "insertHorizontalDivider", label: "Divider", search: "divider horizontal rule line separator", icon: ToolbarIcons.hr, shortcut: "---" }, +] + +const SLASH_COMMAND_SECTIONS = [ + { + section: "Basic blocks", + items: [ + ...CONVERTIBLE_BLOCK_ITEMS.map(item => ({ ...item, insertBelow: true })), + ...INSERT_ONLY_ITEMS, + ] + }, + { + section: "Turn into", + items: CONVERTIBLE_BLOCK_ITEMS.map(({ shortcut, ...item }) => ({ ...item, search: `${item.search} turn into`, filterSuffix: "Turn into" })), + }, + { + section: "Inline", + items: [ + { command: "bold", label: "Bold", search: "bold strong", icon: ToolbarIcons.bold, shortcut: "**text**" }, + { command: "italic", label: "Italic", search: "italic emphasis", icon: ToolbarIcons.italic, shortcut: "_text_" }, + { command: "underline", label: "Underline", search: "underline", icon: ToolbarIcons.underline }, + { command: "strikethrough", label: "Strikethrough", search: "strikethrough strike", icon: ToolbarIcons.strikethrough, shortcut: "~~text~~" }, + { command: "link", label: "Link", search: "link url href", icon: ToolbarIcons.link }, + ] + }, + { + section: "Media", + items: [ + { command: "uploadAttachments", label: "Upload file", search: "upload file attachment image media", icon: ToolbarIcons.attachment }, + ] + }, +] + +export class SlashCommandsExtension extends LexxyExtension { + get enabled() { + return this.editorElement.supportsRichText + } + + initializeEditor() { + // Defer prompt element creation until after editor is interactive. + // The slash menu only appears when the user types "/", so there's + // no need to build it synchronously during editor initialization. + typeof requestIdleCallback === "function" + ? requestIdleCallback(() => this.#buildPromptElement()) + : setTimeout(() => this.#buildPromptElement(), 0) + } + + #buildPromptElement() { + const prompt = createElement("lexxy-prompt") + prompt.setAttribute("trigger", "/") + prompt.setAttribute("dispatch-command", "") + prompt.setAttribute("supports-space-in-searches", "") + + // Static command sections + for (const { section, items } of SLASH_COMMAND_SECTIONS) { + for (const { command, label, search, icon, shortcut, insertBelow, filterSuffix } of items) { + prompt.appendChild(this.#buildCommandItem({ command, label, search, icon, section, shortcut, insertBelow, filterSuffix })) + } + } + + // Dynamic color sections from editor config + const colorConfig = this.editorElement.config.get("highlight.buttons") + if (colorConfig) { + this.#appendColorItems(prompt, colorConfig) + } + + this.editorElement.appendChild(prompt) + } + + #buildCommandItem({ command, label, search, icon, section, payload, selectBlock, shortcut, insertBelow, filterSuffix }) { + const item = createElement("lexxy-prompt-item") + item.setAttribute("search", search) + item.setAttribute("data-command", command) + if (section) item.setAttribute("data-section", section) + if (payload) item.setAttribute("data-command-payload", JSON.stringify(payload)) + if (selectBlock) item.setAttribute("data-command-select-block", "") + if (insertBelow) item.setAttribute("data-insert-below", "") + if (filterSuffix) item.setAttribute("data-filter-suffix", filterSuffix) + + const shortcutHtml = shortcut + ? `${shortcut}` + : "" + + const menuTemplate = document.createElement("template") + menuTemplate.setAttribute("type", "menu") + menuTemplate.innerHTML = `${icon}${label}${shortcutHtml}` + + item.appendChild(menuTemplate) + return item + } + + #buildColorItem({ label, search, section, style, value }) { + const item = createElement("lexxy-prompt-item") + item.setAttribute("search", search) + item.setAttribute("data-command", "toggleHighlight") + item.setAttribute("data-command-payload", JSON.stringify({ [style]: value })) + item.setAttribute("data-command-select-block", "") + item.setAttribute("data-section", section) + + const swatchHtml = style === "color" + ? `A` + : `` + + const menuTemplate = document.createElement("template") + menuTemplate.setAttribute("type", "menu") + menuTemplate.innerHTML = `${swatchHtml}${label}` + + item.appendChild(menuTemplate) + return item + } + + #appendColorItems(prompt, colorConfig) { + if (colorConfig.color?.length) { + for (const value of colorConfig.color) { + const name = colorName(value) + prompt.appendChild(this.#buildColorItem({ + label: `${name} text`, + search: `${name} text color`, + section: "Text color", + style: "color", + value, + })) + } + } + + if (colorConfig["background-color"]?.length) { + for (const value of colorConfig["background-color"]) { + const name = colorName(value) + prompt.appendChild(this.#buildColorItem({ + label: `${name} background`, + search: `${name} background color`, + section: "Background color", + style: "background-color", + value, + })) + } + } + } +} diff --git a/test/browser/helpers/toolbar.js b/test/browser/helpers/toolbar.js index 4eb2b6d68..2b2c57f57 100644 --- a/test/browser/helpers/toolbar.js +++ b/test/browser/helpers/toolbar.js @@ -17,7 +17,7 @@ export async function clickToolbarButton(page, command) { if (FORMAT_DROPDOWN_COMMANDS.has(command)) { await openFormatDropdown(page) } - await page.locator(`[data-command='${command}']`).click() + await page.locator(`lexxy-toolbar [data-command='${command}']`).click() } export async function applyHighlightOption(page, attribute, buttonIndex) { diff --git a/test/browser/tests/formatting/block_formatting.test.js b/test/browser/tests/formatting/block_formatting.test.js index aacb86d11..e3f2c2bc8 100644 --- a/test/browser/tests/formatting/block_formatting.test.js +++ b/test/browser/tests/formatting/block_formatting.test.js @@ -136,7 +136,7 @@ test.describe("Block formatting", () => { await editor.setValue("
First line
Second line
Third line