@37signals/lexxy 0.9.24 → 0.9.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/lexxy.esm.js +120 -14
- package/dist/stylesheets/lexxy-editor.css +10 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ A modern rich text editor for Rails.
|
|
|
21
21
|
|
|
22
22
|
- Bug reports and pull requests are welcome on [GitHub Issues](https://github.com/basecamp/lexxy/issues). Help is especially welcome with [those tagged as "Help Wanted"](https://github.com/basecamp/lexxy/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22help%20wanted%22).
|
|
23
23
|
- For questions and general Lexxy discussion, please use the [Discussions section](https://github.com/basecamp/lexxy/discussions)
|
|
24
|
+
- To build with a development version of Lexical, use `bin/link-to-local-lexical`.
|
|
24
25
|
|
|
25
26
|
## License
|
|
26
27
|
|
package/dist/lexxy.esm.js
CHANGED
|
@@ -1061,9 +1061,9 @@ class ToolbarDropdown extends HTMLElement {
|
|
|
1061
1061
|
const HEADING_BUTTON_SELECTOR = "button.lexxy-heading-button";
|
|
1062
1062
|
|
|
1063
1063
|
const HEADING_PRESETS = [
|
|
1064
|
-
{ label: "Large Heading", name: "heading-large" },
|
|
1065
|
-
{ label: "Medium Heading", name: "heading-medium" },
|
|
1066
|
-
{ label: "Small Heading", name: "heading-small" }
|
|
1064
|
+
{ label: "Large Heading", name: "heading-large", command: "setFormatHeadingLarge" },
|
|
1065
|
+
{ label: "Medium Heading", name: "heading-medium", command: "setFormatHeadingMedium" },
|
|
1066
|
+
{ label: "Small Heading", name: "heading-small", command: "setFormatHeadingSmall" }
|
|
1067
1067
|
];
|
|
1068
1068
|
|
|
1069
1069
|
class HeadingDropdown extends HTMLElement {
|
|
@@ -1093,6 +1093,14 @@ class HeadingDropdown extends HTMLElement {
|
|
|
1093
1093
|
}
|
|
1094
1094
|
}
|
|
1095
1095
|
|
|
1096
|
+
static commandFor(index) {
|
|
1097
|
+
if (index < HEADING_PRESETS.length) {
|
|
1098
|
+
return HEADING_PRESETS[index].command
|
|
1099
|
+
} else {
|
|
1100
|
+
return "applyHeadingFormat"
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1096
1104
|
#listeners = new ListenerBin()
|
|
1097
1105
|
|
|
1098
1106
|
connectedCallback() {
|
|
@@ -1155,6 +1163,7 @@ class HeadingDropdown extends HTMLElement {
|
|
|
1155
1163
|
const button = document.createElement("button");
|
|
1156
1164
|
button.type = "button";
|
|
1157
1165
|
button.dataset.heading = tag;
|
|
1166
|
+
button.dataset.command = HeadingDropdown.commandFor(index);
|
|
1158
1167
|
button.classList.add("lexxy-heading-button");
|
|
1159
1168
|
button.name = name;
|
|
1160
1169
|
button.title = label;
|
|
@@ -1169,7 +1178,7 @@ class HeadingDropdown extends HTMLElement {
|
|
|
1169
1178
|
if (!this.#editor) return
|
|
1170
1179
|
|
|
1171
1180
|
event.preventDefault();
|
|
1172
|
-
this.#editor.dispatchCommand(
|
|
1181
|
+
this.#editor.dispatchCommand(button.dataset.command, button.dataset.heading);
|
|
1173
1182
|
this.#editor.focus();
|
|
1174
1183
|
}));
|
|
1175
1184
|
});
|
|
@@ -1938,14 +1947,29 @@ function $shrinkSelectionPastBlockEdges(selection) {
|
|
|
1938
1947
|
if (!anchorBlock || !focusBlock || anchorBlock.is(focusBlock)) return
|
|
1939
1948
|
|
|
1940
1949
|
if ($isAtBlockEnd(selection.anchor, anchorBlock)) {
|
|
1941
|
-
const nextBlock = anchorBlock
|
|
1950
|
+
const nextBlock = $nearestElementSibling(anchorBlock, "next");
|
|
1942
1951
|
if (nextBlock) selection.anchor.set(nextBlock.getKey(), 0, "element");
|
|
1943
1952
|
}
|
|
1944
1953
|
|
|
1945
1954
|
if ($isAtBlockStart(selection.focus, focusBlock)) {
|
|
1946
|
-
const previousBlock = focusBlock
|
|
1955
|
+
const previousBlock = $nearestElementSibling(focusBlock, "previous");
|
|
1947
1956
|
if (previousBlock) selection.focus.set(previousBlock.getKey(), previousBlock.getChildrenSize(), "element");
|
|
1948
1957
|
}
|
|
1958
|
+
|
|
1959
|
+
// Shrinking moves the endpoints toward each other, so they can cross when
|
|
1960
|
+
// both were flush against block edges; callers assume a forward selection.
|
|
1961
|
+
$ensureForwardRangeSelection(selection);
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
// Top-level decorator blocks (attachments, horizontal dividers) can't hold a
|
|
1965
|
+
// selection point, so walk past them to the nearest element sibling. Returns
|
|
1966
|
+
// null when no element sibling exists in that direction.
|
|
1967
|
+
function $nearestElementSibling(block, direction) {
|
|
1968
|
+
let sibling = block;
|
|
1969
|
+
do {
|
|
1970
|
+
sibling = direction === "next" ? sibling.getNextSibling() : sibling.getPreviousSibling();
|
|
1971
|
+
} while (sibling && !$isElementNode(sibling))
|
|
1972
|
+
return sibling
|
|
1949
1973
|
}
|
|
1950
1974
|
|
|
1951
1975
|
function $isAtBlockEnd(point, block) {
|
|
@@ -3747,6 +3771,9 @@ const COMMANDS = [
|
|
|
3747
3771
|
"unlink",
|
|
3748
3772
|
"toggleHighlight",
|
|
3749
3773
|
"removeHighlight",
|
|
3774
|
+
"setFormatHeadingLarge",
|
|
3775
|
+
"setFormatHeadingMedium",
|
|
3776
|
+
"setFormatHeadingSmall",
|
|
3750
3777
|
"setFormatParagraph",
|
|
3751
3778
|
"applyHeadingFormat",
|
|
3752
3779
|
"clearFormatting",
|
|
@@ -3940,6 +3967,18 @@ class CommandDispatcher {
|
|
|
3940
3967
|
$insertNodeToNearestRoot(new HorizontalDividerNode);
|
|
3941
3968
|
}
|
|
3942
3969
|
|
|
3970
|
+
dispatchSetFormatHeadingLarge() {
|
|
3971
|
+
this.#applyConfiguredHeadingFormat(0);
|
|
3972
|
+
}
|
|
3973
|
+
|
|
3974
|
+
dispatchSetFormatHeadingMedium() {
|
|
3975
|
+
this.#applyConfiguredHeadingFormat(1);
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3978
|
+
dispatchSetFormatHeadingSmall() {
|
|
3979
|
+
this.#applyConfiguredHeadingFormat(2);
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3943
3982
|
dispatchSetFormatParagraph() {
|
|
3944
3983
|
this.contents.applyParagraphFormat();
|
|
3945
3984
|
}
|
|
@@ -3996,6 +4035,11 @@ class CommandDispatcher {
|
|
|
3996
4035
|
this.#listeners.dispose();
|
|
3997
4036
|
}
|
|
3998
4037
|
|
|
4038
|
+
#applyConfiguredHeadingFormat(index) {
|
|
4039
|
+
const tag = this.editorElement.config.get("headings")[index];
|
|
4040
|
+
if (tag) this.contents.applyHeadingFormat(tag);
|
|
4041
|
+
}
|
|
4042
|
+
|
|
3999
4043
|
#registerCommands() {
|
|
4000
4044
|
for (const command of COMMANDS) {
|
|
4001
4045
|
const methodName = `dispatch${capitalize(command)}`;
|
|
@@ -5566,11 +5610,11 @@ class ActionTextAttachmentUploadNode extends ActionTextAttachmentNode {
|
|
|
5566
5610
|
}
|
|
5567
5611
|
|
|
5568
5612
|
#forgetUploadRequest() {
|
|
5569
|
-
this.#editorElement
|
|
5613
|
+
this.#editorElement?.uploadRequests.forget(this.getKey());
|
|
5570
5614
|
}
|
|
5571
5615
|
|
|
5572
5616
|
#rememberUploadRequest(request) {
|
|
5573
|
-
this.#editorElement
|
|
5617
|
+
this.#editorElement?.uploadRequests.track(this.getKey(), request);
|
|
5574
5618
|
}
|
|
5575
5619
|
|
|
5576
5620
|
get #editorElement() {
|
|
@@ -5876,13 +5920,27 @@ class PastedContentFormatter {
|
|
|
5876
5920
|
}
|
|
5877
5921
|
|
|
5878
5922
|
format() {
|
|
5923
|
+
this.#stripStyleElements();
|
|
5879
5924
|
this.#unwrapPlaceholderAnchors();
|
|
5880
5925
|
this.#stripTableCellColorStyles();
|
|
5926
|
+
this.#unwrapWrappedListChildren();
|
|
5881
5927
|
this.#nestStrayListChildren();
|
|
5882
5928
|
this.#stripStrayListChildren();
|
|
5883
5929
|
return this.doc
|
|
5884
5930
|
}
|
|
5885
5931
|
|
|
5932
|
+
// Spreadsheets (e.g. Excel) copy a <style> block whose rules (td { color:
|
|
5933
|
+
// black }, .xlNN { ... }) cascade onto the imported text. That color rides
|
|
5934
|
+
// in through the cascade rather than an inline style, so it slips past both
|
|
5935
|
+
// the cell-level stripping below and the paste style canonicalizer, leaving
|
|
5936
|
+
// pasted tables with foreign text colors that don't adapt to the theme. Drop
|
|
5937
|
+
// foreign style sheets so nothing cascades into imported content.
|
|
5938
|
+
#stripStyleElements() {
|
|
5939
|
+
for (const style of this.doc.querySelectorAll("style")) {
|
|
5940
|
+
style.remove();
|
|
5941
|
+
}
|
|
5942
|
+
}
|
|
5943
|
+
|
|
5886
5944
|
// Anchors with non-meaningful hrefs (e.g. "#", "") appear in content copied
|
|
5887
5945
|
// from rendered views where mentions and interactive elements are wrapped in
|
|
5888
5946
|
// <a href="#"> tags. Unwrap them so their text content pastes as plain text
|
|
@@ -5907,6 +5965,29 @@ class PastedContentFormatter {
|
|
|
5907
5965
|
}
|
|
5908
5966
|
}
|
|
5909
5967
|
|
|
5968
|
+
// Some sources wrap runs of <li>s in a stray element (e.g. a <div> directly
|
|
5969
|
+
// inside the list). Dissolve such wrappers so their items become direct
|
|
5970
|
+
// list children and any nested list they hide becomes visible to
|
|
5971
|
+
// #nestStrayListChildren below.
|
|
5972
|
+
#unwrapWrappedListChildren() {
|
|
5973
|
+
for (const list of this.doc.querySelectorAll("ol, ul")) {
|
|
5974
|
+
let wrapper = this.#wrappedListChild(list);
|
|
5975
|
+
while (wrapper) {
|
|
5976
|
+
wrapper.replaceWith(...wrapper.childNodes);
|
|
5977
|
+
wrapper = this.#wrappedListChild(list);
|
|
5978
|
+
}
|
|
5979
|
+
}
|
|
5980
|
+
}
|
|
5981
|
+
|
|
5982
|
+
#wrappedListChild(list) {
|
|
5983
|
+
for (const child of list.children) {
|
|
5984
|
+
if (child.tagName !== "LI" && !this.#isNestedList(child) && this.#containsListItems(child)) {
|
|
5985
|
+
return child
|
|
5986
|
+
}
|
|
5987
|
+
}
|
|
5988
|
+
return null
|
|
5989
|
+
}
|
|
5990
|
+
|
|
5910
5991
|
// Some sources (e.g. Gmail) nest a sublist as a direct child of the parent
|
|
5911
5992
|
// <ol>/<ul> instead of inside a <li>. Move each nested list into its
|
|
5912
5993
|
// preceding <li> so the import preserves the nesting instead of dropping it.
|
|
@@ -5923,15 +6004,40 @@ class PastedContentFormatter {
|
|
|
5923
6004
|
}
|
|
5924
6005
|
}
|
|
5925
6006
|
|
|
5926
|
-
// Only <li> is a valid child of a list
|
|
5927
|
-
//
|
|
6007
|
+
// Only <li> is a valid child of a list. Unwrap remaining stray children
|
|
6008
|
+
// that still hold list items (a nested list with no preceding <li> to nest
|
|
6009
|
+
// under) so the items survive, and drop stray <br>/whitespace so the import
|
|
6010
|
+
// doesn't wrap them into an empty leading item.
|
|
5928
6011
|
#stripStrayListChildren() {
|
|
5929
6012
|
for (const list of this.doc.querySelectorAll("ol, ul")) {
|
|
5930
|
-
|
|
5931
|
-
|
|
5932
|
-
|
|
6013
|
+
let stray = this.#firstStrayListChild(list);
|
|
6014
|
+
while (stray) {
|
|
6015
|
+
if (this.#containsListItems(stray)) {
|
|
6016
|
+
stray.replaceWith(...stray.childNodes);
|
|
6017
|
+
} else {
|
|
6018
|
+
stray.remove();
|
|
6019
|
+
}
|
|
6020
|
+
|
|
6021
|
+
stray = this.#firstStrayListChild(list);
|
|
6022
|
+
}
|
|
6023
|
+
}
|
|
6024
|
+
}
|
|
6025
|
+
|
|
6026
|
+
#firstStrayListChild(list) {
|
|
6027
|
+
for (const child of list.childNodes) {
|
|
6028
|
+
if (child.nodeType !== Node.ELEMENT_NODE || child.tagName !== "LI") {
|
|
6029
|
+
return child
|
|
5933
6030
|
}
|
|
5934
6031
|
}
|
|
6032
|
+
return null
|
|
6033
|
+
}
|
|
6034
|
+
|
|
6035
|
+
#isNestedList(node) {
|
|
6036
|
+
return node.nodeType === Node.ELEMENT_NODE && (node.tagName === "OL" || node.tagName === "UL")
|
|
6037
|
+
}
|
|
6038
|
+
|
|
6039
|
+
#containsListItems(node) {
|
|
6040
|
+
return node.nodeType === Node.ELEMENT_NODE && node.querySelector("li") !== null
|
|
5935
6041
|
}
|
|
5936
6042
|
}
|
|
5937
6043
|
|
|
@@ -9412,7 +9518,7 @@ class LexicalEditorElement extends HTMLElement {
|
|
|
9412
9518
|
{ label: "Normal", command: "setFormatParagraph", tag: null },
|
|
9413
9519
|
...headings.map((tag, index) => ({
|
|
9414
9520
|
label: HeadingDropdown.labelFor(tag, index),
|
|
9415
|
-
command:
|
|
9521
|
+
command: HeadingDropdown.commandFor(index),
|
|
9416
9522
|
tag
|
|
9417
9523
|
}))
|
|
9418
9524
|
]
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
}
|
|
10
10
|
--lexxy-toolbar-gap: 2px;
|
|
11
11
|
--lexxy-toolbar-spacing: 0.5ch;
|
|
12
|
+
--lexxy-toolbar-padding: 2px;
|
|
13
|
+
--lexxy-toolbar-border-size: 1px;
|
|
14
|
+
--lexxy-toolbar-height: calc(var(--lexxy-toolbar-button-size) + 2 * var(--lexxy-toolbar-padding) + var(--lexxy-toolbar-border-size));
|
|
12
15
|
|
|
13
16
|
border: 1px solid var(--lexxy-color-ink-lighter);
|
|
14
17
|
border-radius: calc(var(--lexxy-radius) + var(--lexxy-toolbar-gap));
|
|
@@ -408,6 +411,11 @@
|
|
|
408
411
|
}
|
|
409
412
|
}
|
|
410
413
|
|
|
414
|
+
/* Reserve the mounted height before upgrade so it doesn't shift layout on mount. */
|
|
415
|
+
:where(lexxy-editor:not(:defined)) {
|
|
416
|
+
min-block-size: calc(var(--lexxy-toolbar-height) + var(--lexxy-editor-rows) + 2 * var(--lexxy-editor-padding));
|
|
417
|
+
}
|
|
418
|
+
|
|
411
419
|
/* Placeholder */
|
|
412
420
|
:where(.lexxy-editor--empty) {
|
|
413
421
|
.lexxy-editor__content:not(:has(h1, h2, h3, h4, h5, h6, table, ul, ol, figure, .attachment-gallery))::before {
|
|
@@ -461,13 +469,13 @@
|
|
|
461
469
|
:where(lexxy-toolbar) {
|
|
462
470
|
--lexxy-toolbar-icon-size: 1.125em;
|
|
463
471
|
|
|
464
|
-
border-block-end:
|
|
472
|
+
border-block-end: var(--lexxy-toolbar-border-size) solid var(--lexxy-color-ink-lighter);
|
|
465
473
|
color: currentColor;
|
|
466
474
|
display: flex;
|
|
467
475
|
font-size: inherit;
|
|
468
476
|
gap: var(--lexxy-toolbar-gap);
|
|
469
477
|
max-inline-size: 100%;
|
|
470
|
-
padding:
|
|
478
|
+
padding: var(--lexxy-toolbar-padding);
|
|
471
479
|
position: relative;
|
|
472
480
|
|
|
473
481
|
@container (width < 300px) {
|