@37signals/lexxy 0.9.31 → 0.9.33

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.
@@ -44,12 +44,13 @@ function highlightElement(preElement) {
44
44
  const grammar = Prism.languages?.[language];
45
45
  if (!grammar) return
46
46
 
47
- // Read the source text and <mark> ranges in a single walk, before Prism
48
- // rewrites the element. Sharing one traversal keeps the highlight offsets
49
- // aligned with the code string and preserves leading whitespace — deriving
50
- // either of them separately (e.g. textContent through DOMParser) collapses
51
- // leading whitespace and shifts every range, re-indenting the rendered block.
52
- const { code, highlights } = extractCodeAndHighlights(preElement);
47
+ // Read the source text and the <mark> and <a> ranges in a single walk,
48
+ // before Prism rewrites the element. Sharing one traversal keeps the range
49
+ // offsets aligned with the code string and preserves leading whitespace —
50
+ // deriving either of them separately (e.g. textContent through DOMParser)
51
+ // collapses leading whitespace and shifts every range, re-indenting the
52
+ // rendered block.
53
+ const { code, highlights, links } = extractCodeAndMarkup(preElement);
53
54
 
54
55
  const highlightedHtml = Prism.highlight(code, grammar, language);
55
56
  preElement.innerHTML = highlightedHtml;
@@ -58,18 +59,24 @@ function highlightElement(preElement) {
58
59
  applyHighlightRanges(preElement, highlights);
59
60
  }
60
61
 
62
+ if (links.length > 0) {
63
+ applyLinkRanges(preElement, links);
64
+ }
65
+
61
66
  preElement.dataset.highlighted = "true";
62
67
  }
63
68
 
64
- // Walk the <pre> once, building Prism's source text and the <mark> ranges
65
- // together: a text node contributes its text verbatim, a <br> contributes a
66
- // newline, and a <mark> records the slice of code it covers. Because both
67
- // outputs come from the same walk, every range offset is just a position in
68
- // `code` — so the highlights can't drift out of sync with the source, and the
69
- // block's leading whitespace survives (HTML parsing would collapse it).
70
- function extractCodeAndHighlights(preElement) {
69
+ // Walk the <pre> once, building Prism's source text and the <mark> and <a>
70
+ // ranges together: a text node contributes its text verbatim, a <br>
71
+ // contributes a newline, and a <mark> or <a> records the slice of code it
72
+ // covers. Because all outputs come from the same walk, every range offset is
73
+ // just a position in `code` — so the markup can't drift out of sync with the
74
+ // source, and the block's leading whitespace survives (HTML parsing would
75
+ // collapse it).
76
+ function extractCodeAndMarkup(preElement) {
71
77
  const root = preElement.querySelector("code") || preElement;
72
78
  const highlights = [];
79
+ const links = [];
73
80
  let code = "";
74
81
 
75
82
  function walk(node) {
@@ -87,6 +94,12 @@ function extractCodeAndHighlights(preElement) {
87
94
  if (style) {
88
95
  highlights.push({ start, end: code.length, style });
89
96
  }
97
+ } else if (node.tagName === "A" && node.getAttribute("href")) {
98
+ const start = code.length;
99
+ for (const child of node.childNodes) {
100
+ walk(child);
101
+ }
102
+ links.push({ start, end: code.length, attributes: linkAttributes(node) });
90
103
  } else {
91
104
  for (const child of node.childNodes) {
92
105
  walk(child);
@@ -99,7 +112,17 @@ function extractCodeAndHighlights(preElement) {
99
112
  walk(child);
100
113
  }
101
114
 
102
- return { code, highlights }
115
+ return { code, highlights, links }
116
+ }
117
+
118
+ function linkAttributes(element) {
119
+ const attributes = { href: element.getAttribute("href") };
120
+ for (const name of [ "target", "rel", "title" ]) {
121
+ if (element.getAttribute(name)) {
122
+ attributes[name] = element.getAttribute(name);
123
+ }
124
+ }
125
+ return attributes
103
126
  }
104
127
 
105
128
  function extractStyle(element) {
@@ -109,16 +132,32 @@ function extractStyle(element) {
109
132
  return parts.length > 0 ? parts.join(" ") : null
110
133
  }
111
134
 
112
- // Wrap character ranges in <mark> elements within a Prism-highlighted DOM tree.
113
- // Each range is applied independently, re-collecting text nodes each time to
114
- // account for splits from previous ranges.
135
+ // Wrap character ranges in <mark> or <a> elements within a Prism-highlighted
136
+ // DOM tree. Each range is applied independently, re-collecting text nodes
137
+ // each time to account for splits from previous ranges.
115
138
  function applyHighlightRanges(element, highlights) {
116
139
  for (const { start, end, style } of highlights) {
117
- wrapRange(element, start, end, style);
140
+ wrapRange(element, start, end, () => {
141
+ const mark = document.createElement("mark");
142
+ mark.setAttribute("style", style);
143
+ return mark
144
+ });
145
+ }
146
+ }
147
+
148
+ function applyLinkRanges(element, links) {
149
+ for (const { start, end, attributes } of links) {
150
+ wrapRange(element, start, end, () => {
151
+ const anchor = document.createElement("a");
152
+ for (const [ name, value ] of Object.entries(attributes)) {
153
+ anchor.setAttribute(name, value);
154
+ }
155
+ return anchor
156
+ });
118
157
  }
119
158
  }
120
159
 
121
- function wrapRange(container, rangeStart, rangeEnd, style) {
160
+ function wrapRange(container, rangeStart, rangeEnd, createWrapper) {
122
161
  const textNodes = collectTextNodes(container);
123
162
 
124
163
  // Process in reverse so DOM mutations don't shift earlier text node offsets
@@ -133,14 +172,13 @@ function wrapRange(container, rangeStart, rangeEnd, style) {
133
172
  const text = node.textContent;
134
173
  const parent = node.parentNode;
135
174
 
136
- const mark = document.createElement("mark");
137
- mark.setAttribute("style", style);
138
- mark.textContent = text.slice(relStart, relEnd);
175
+ const wrapper = createWrapper();
176
+ wrapper.textContent = text.slice(relStart, relEnd);
139
177
 
140
178
  if (relEnd < text.length) {
141
179
  parent.insertBefore(document.createTextNode(text.slice(relEnd)), node.nextSibling);
142
180
  }
143
- parent.insertBefore(mark, node.nextSibling);
181
+ parent.insertBefore(wrapper, node.nextSibling);
144
182
 
145
183
  if (relStart > 0) {
146
184
  node.textContent = text.slice(0, relStart);
@@ -210,12 +210,6 @@
210
210
  inset-inline-end: 0;
211
211
  position: absolute;
212
212
  }
213
-
214
- lexxy-node-delete-button {
215
- inset-block-start: 1.5ch;
216
- inset-inline-end: 1.5ch;
217
- inset-inline-start: unset;
218
- }
219
213
  }
220
214
 
221
215
  &.attachment--error {
@@ -327,15 +321,6 @@
327
321
  outline-offset: var(--lexxy-focus-ring-offset);
328
322
  }
329
323
 
330
- .attachment--file {
331
- lexxy-node-delete-button {
332
- inset-block-start: 50%;
333
- inset-inline-end: 1ch;
334
- inset-inline-start: unset;
335
- transform: translate(0, -50%);
336
- }
337
- }
338
-
339
324
  /* Image galleries */
340
325
  /* ------------------------------------------------------------------------ */
341
326
 
@@ -372,43 +357,28 @@
372
357
  }
373
358
  }
374
359
 
375
- .attachment__caption {
376
- textarea {
377
- background: transparent;
378
- block-size: fit-content;
379
- border: 0;
380
- border-radius: var(--lexxy-radius);
381
- box-sizing: border-box;
382
- color: inherit;
360
+ .attachment__caption--editable {
361
+ box-sizing: border-box;
362
+ cursor: text;
363
+
364
+ .attachment__caption-text {
383
365
  display: block;
384
- font-size: inherit;
385
- font-family: inherit;
386
- inline-size: 100%;
387
- max-inline-size: 100%;
388
- padding: 0;
366
+ min-block-size: 1lh;
367
+ overflow-wrap: anywhere;
368
+ white-space: pre-wrap;
389
369
  text-align: center;
370
+ }
390
371
 
391
- @supports (resize: none) {
392
- resize: none;
393
- }
394
-
395
- &:focus {
396
- background: var(--lexxy-color-canvas);
397
- outline: 0;
398
- }
399
-
400
- &:placeholder-shown {
401
- overflow: hidden;
402
- text-overflow: ellipsis;
403
- white-space: nowrap;
404
- }
405
-
406
- @supports (field-sizing: content) {
407
- field-sizing: content;
408
- inline-size: 100%;
409
- }
372
+ &[data-placeholder] .attachment__caption-text {
373
+ overflow: hidden;
374
+ text-overflow: ellipsis;
375
+ white-space: nowrap;
410
376
  }
411
377
  }
378
+
379
+ .attachment__caption--editing .attachment__caption-text {
380
+ display: none;
381
+ }
412
382
  }
413
383
 
414
384
  /* Reserve the mounted height before upgrade so it doesn't shift layout on mount. */
@@ -636,7 +606,7 @@
636
606
  padding: 1ch;
637
607
  position: relative;
638
608
 
639
- &[aria-pressed="true"] {
609
+ &[aria-checked="true"] {
640
610
  background-color: var(--lexxy-color-selected);
641
611
 
642
612
  &:hover {
@@ -782,6 +752,10 @@
782
752
  grid-template-columns: repeat(auto-fill, minmax(var(--lexxy-toolbar-button-size), 1fr));
783
753
  max-inline-size: var(--max-inline-size);
784
754
 
755
+ .lexxy-highlight-colors__group {
756
+ display: contents;
757
+ }
758
+
785
759
  button {
786
760
  block-size: unset;
787
761
  inline-size: 100%;
@@ -796,7 +770,7 @@
796
770
  position: absolute;
797
771
  }
798
772
 
799
- &[aria-pressed="true"] {
773
+ &[aria-checked="true"] {
800
774
  background-color: transparent;
801
775
  box-shadow: 0 0 0 2px currentColor inset;
802
776
 
@@ -830,8 +804,7 @@
830
804
  position: absolute;
831
805
  z-index: 2;
832
806
 
833
- button,
834
- summary {
807
+ button {
835
808
  align-items: center;
836
809
  background: transparent;
837
810
  block-size: var(--button-size);
@@ -849,7 +822,7 @@
849
822
 
850
823
  @media(any-hover: hover) {
851
824
  &:hover:not([aria-disabled="true"]),
852
- [open] &:is(summary) {
825
+ &.lexxy-table-control__more-menu-trigger[aria-expanded="true"] {
853
826
  background: var(--lexxy-color-ink-medium);
854
827
 
855
828
  svg {
@@ -880,8 +853,10 @@
880
853
  }
881
854
  }
882
855
 
883
- summary {
856
+ .lexxy-table-control__more-menu-trigger {
857
+ aspect-ratio: auto;
884
858
  box-shadow: 0 0 0 1px inset rgba(128, 128, 128, 0.3);
859
+ font-weight: normal;
885
860
  padding: 0 1.25ch;
886
861
  }
887
862
 
@@ -909,6 +884,7 @@
909
884
  white-space: nowrap;
910
885
 
911
886
  .lexxy-table-control__more-menu {
887
+ display: flex;
912
888
  gap: 0;
913
889
  position: relative;
914
890
 
@@ -924,6 +900,10 @@
924
900
  position: absolute;
925
901
  transform: translateX(-50%);
926
902
 
903
+ &[hidden] {
904
+ display: none;
905
+ }
906
+
927
907
  button {
928
908
  aspect-ratio: unset;
929
909
  flex-direction: row;
@@ -994,20 +974,40 @@
994
974
 
995
975
 
996
976
  /* --------------------------------------------------------------------------
997
- /* Attachment delete button */
977
+ /* Attachment toolbar */
998
978
 
999
- &:is(lexxy-node-delete-button) {
1000
- inset-block-start: 0;
1001
- inset-inline-start: 100%;
1002
- opacity: 0;
1003
- pointer-events: none;
979
+ &:is(lexxy-attachment-toolbar) {
980
+ display: flex;
981
+ /* The anchor is measured in px from the editor's top-left, so these stay physical. */
982
+ left: calc(var(--lexxy-anchor-left) + var(--lexxy-anchor-width));
983
+ top: var(--lexxy-anchor-top);
984
+ transform: translate(-100%, 0);
1004
985
 
1005
- .node--selected & {
1006
- opacity: 1;
1007
- pointer-events: auto;
986
+ &[hidden] {
987
+ display: none;
1008
988
  }
1009
989
 
1010
- .lexxy-node-delete:hover {
990
+ /* The divider is thinner than the button. */
991
+ &[data-node-type="horizontal_divider"] {
992
+ --button-size: 1.8lh;
993
+
994
+ top: calc(var(--lexxy-anchor-top) + var(--lexxy-anchor-height) / 2);
995
+ transform: translate(calc(-100% - 0.5ch), -50%);
996
+ }
997
+
998
+ &[data-presentation="inline"] {
999
+ --button-size: 1.6lh;
1000
+
1001
+ top: calc(var(--lexxy-anchor-top) + var(--lexxy-anchor-height) / 2);
1002
+ transform: translate(0.5ch, -50%);
1003
+
1004
+ &[data-overflow] {
1005
+ transform: translate(-100%, -50%);
1006
+ }
1007
+ }
1008
+
1009
+ .lexxy-attachment-toolbar__remove:hover,
1010
+ .lexxy-attachment-toolbar__remove:focus-visible {
1011
1011
  background-color: var(--lexxy-color-red);
1012
1012
  }
1013
1013
  }
@@ -1135,20 +1135,49 @@ action-text-attachment[content-type^="application/vnd.actiontext"] {
1135
1135
 
1136
1136
  outline: none;
1137
1137
  }
1138
+ }
1138
1139
 
1139
- lexxy-node-delete-button {
1140
- inset-inline-start: 0;
1141
- line-height: 1lh;
1140
+ /* --------------------------------------------------------------------------
1141
+ /* Screen readers need access to announcements and selection labels. */
1142
+ :where(lexxy-live-region, .lexxy-fake-selection) {
1143
+ block-size: 1px;
1144
+ border: 0;
1145
+ clip-path: inset(50%);
1146
+ inline-size: 1px;
1147
+ margin: -1px;
1148
+ overflow: hidden;
1149
+ padding: 0;
1150
+ position: absolute;
1151
+ white-space: nowrap;
1152
+ }
1142
1153
 
1143
- .lexxy-floating-controls__group {
1144
- --button-size: 1lh;
1154
+ /* The DOM Selection API requires selectable text. */
1155
+ .lexxy-fake-selection {
1156
+ -webkit-user-select: text;
1145
1157
 
1146
- background-color: var(--lexxy-color-accent-dark);
1147
- padding: 0;
1158
+ @supports (user-select: text) {
1159
+ user-select: text;
1160
+ }
1161
+ }
1148
1162
 
1149
- button {
1150
- border-radius: 50%;
1151
- }
1152
- }
1163
+
1164
+ /* Screen readers need the caption field outside the editor's textbox. */
1165
+ :where(.lexxy-caption-editor) {
1166
+ position: absolute;
1167
+ z-index: 2;
1168
+ box-sizing: border-box;
1169
+ background: var(--lexxy-color-canvas);
1170
+ border: 0;
1171
+ border-radius: var(--lexxy-radius);
1172
+ outline: 0;
1173
+ padding: 0;
1174
+ overflow: hidden;
1175
+
1176
+ @supports (resize: none) {
1177
+ resize: none;
1178
+ }
1179
+
1180
+ &[hidden] {
1181
+ display: none;
1153
1182
  }
1154
1183
  }
@@ -49,11 +49,11 @@
49
49
 
50
50
  --highlight-bg-1: rgba(229, 223, 6, 0.3);
51
51
  --highlight-bg-2: rgba(255, 185, 87, 0.3);
52
- --highlight-bg-3: rgba(255, 118, 118, 0.3);
52
+ --highlight-bg-3: rgba(255, 118, 118, 0.3);
53
53
  --highlight-bg-4: rgba(248, 137, 216, 0.3);
54
- --highlight-bg-5: rgba(190, 165, 255, 0.3);
54
+ --highlight-bg-5: rgba(190, 165, 255, 0.3);
55
55
  --highlight-bg-6: rgba(124, 192, 252, 0.3);
56
- --highlight-bg-7: rgba(140, 255, 129, 0.3);
56
+ --highlight-bg-7: rgba(140, 255, 129, 0.3);
57
57
  --highlight-bg-8: rgba(221, 170, 123, 0.3);
58
58
  --highlight-bg-9: rgba(200, 200, 200, 0.3);
59
59
 
@@ -84,4 +84,4 @@
84
84
  --lexxy-radius: 0.5ch;
85
85
  --lexxy-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
86
86
  --lexxy-z-popup: 1000;
87
- }
87
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.9.31",
3
+ "version": "0.9.33",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",