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
199 changes: 168 additions & 31 deletions app/assets/stylesheets/lexxy-editor.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -1001,33 +1003,52 @@
--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);
list-style: none;
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) {
Expand All @@ -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 {
Expand All @@ -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 */
Expand Down
2 changes: 1 addition & 1 deletion app/assets/stylesheets/lexxy-variables.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
9 changes: 8 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions src/editor/extensions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
82 changes: 82 additions & 0 deletions src/editor/markdown/horizontal_rule_transformer.js
Original file line number Diff line number Diff line change
@@ -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
)
}
Loading
Loading