@37signals/lexxy 0.8.2-beta → 0.8.6-beta

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.
@@ -50,6 +50,10 @@ function generateDomId(prefix) {
50
50
  return `${prefix}-${randomPart}`
51
51
  }
52
52
 
53
+ function extractPlainTextFromHtml(innerHtml = "") {
54
+ return parseHtml(innerHtml).body.textContent.trim()
55
+ }
56
+
53
57
  function highlightCode() {
54
58
  const elements = document.querySelectorAll("pre[data-language]");
55
59
 
@@ -65,12 +69,123 @@ function highlightElement(preElement) {
65
69
  const grammar = Prism.languages?.[language];
66
70
  if (!grammar) return
67
71
 
72
+ // Extract highlight ranges before Prism destroys <mark> elements
73
+ const highlights = extractHighlightRanges(preElement);
74
+
68
75
  // unescape HTML entities in the code block
69
76
  code = new DOMParser().parseFromString(code, "text/html").body.textContent || "";
70
77
 
71
78
  const highlightedHtml = Prism.highlight(code, grammar, language);
72
79
  const codeElement = createElement("code", { "data-language": language, innerHTML: highlightedHtml });
80
+
81
+ if (highlights.length > 0) {
82
+ applyHighlightRanges(codeElement, highlights);
83
+ }
84
+
73
85
  preElement.replaceWith(codeElement);
74
86
  }
75
87
 
76
- export { addBlockSpacing, createAttachmentFigure, createElement, dispatch, generateDomId, highlightCode, isPreviewableImage, parseHtml };
88
+ // Walk the DOM tree inside a <pre> element and build a list of
89
+ // { start, end, style } ranges for every <mark> element found.
90
+ function extractHighlightRanges(preElement) {
91
+ const ranges = [];
92
+ const root = preElement.querySelector("code") || preElement;
93
+
94
+ let offset = 0;
95
+
96
+ function walk(node) {
97
+ if (node.nodeType === Node.TEXT_NODE) {
98
+ offset += node.textContent.length;
99
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
100
+ if (node.tagName === "BR") {
101
+ offset += 1;
102
+ return
103
+ }
104
+
105
+ const isMark = node.tagName === "MARK";
106
+ const start = offset;
107
+
108
+ for (const child of node.childNodes) {
109
+ walk(child);
110
+ }
111
+
112
+ if (isMark) {
113
+ const style = extractStyle(node);
114
+ if (style) {
115
+ ranges.push({ start, end: offset, style });
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ for (const child of root.childNodes) {
122
+ walk(child);
123
+ }
124
+
125
+ return ranges
126
+ }
127
+
128
+ function extractStyle(element) {
129
+ const parts = [];
130
+ if (element.style?.color) parts.push(`color: ${element.style.color};`);
131
+ if (element.style?.backgroundColor) parts.push(`background-color: ${element.style.backgroundColor};`);
132
+ return parts.length > 0 ? parts.join(" ") : null
133
+ }
134
+
135
+ // Wrap character ranges in <mark> elements within a Prism-highlighted DOM tree.
136
+ // Each range is applied independently, re-collecting text nodes each time to
137
+ // account for splits from previous ranges.
138
+ function applyHighlightRanges(element, highlights) {
139
+ for (const { start, end, style } of highlights) {
140
+ wrapRange(element, start, end, style);
141
+ }
142
+ }
143
+
144
+ function wrapRange(container, rangeStart, rangeEnd, style) {
145
+ const textNodes = collectTextNodes(container);
146
+
147
+ // Process in reverse so DOM mutations don't shift earlier text node offsets
148
+ for (let i = textNodes.length - 1; i >= 0; i--) {
149
+ const { node, start: nodeStart, end: nodeEnd } = textNodes[i];
150
+ const overlapStart = Math.max(rangeStart, nodeStart);
151
+ const overlapEnd = Math.min(rangeEnd, nodeEnd);
152
+ if (overlapStart >= overlapEnd) continue
153
+
154
+ const relStart = overlapStart - nodeStart;
155
+ const relEnd = overlapEnd - nodeStart;
156
+ const text = node.textContent;
157
+ const parent = node.parentNode;
158
+
159
+ const mark = document.createElement("mark");
160
+ mark.setAttribute("style", style);
161
+ mark.textContent = text.slice(relStart, relEnd);
162
+
163
+ if (relEnd < text.length) {
164
+ parent.insertBefore(document.createTextNode(text.slice(relEnd)), node.nextSibling);
165
+ }
166
+ parent.insertBefore(mark, node.nextSibling);
167
+
168
+ if (relStart > 0) {
169
+ node.textContent = text.slice(0, relStart);
170
+ } else {
171
+ parent.removeChild(node);
172
+ }
173
+ }
174
+ }
175
+
176
+ function collectTextNodes(root) {
177
+ const nodes = [];
178
+ let offset = 0;
179
+ const walker = document.createTreeWalker(root, 4 /* NodeFilter.SHOW_TEXT */);
180
+
181
+ let node;
182
+ while ((node = walker.nextNode())) {
183
+ const length = node.textContent.length;
184
+ nodes.push({ node, start: offset, end: offset + length });
185
+ offset += length;
186
+ }
187
+
188
+ return nodes
189
+ }
190
+
191
+ export { addBlockSpacing, createAttachmentFigure, createElement, dispatch, extractPlainTextFromHtml, generateDomId, highlightCode, isPreviewableImage, parseHtml };
@@ -55,6 +55,10 @@
55
55
  text-decoration: underline;
56
56
  }
57
57
 
58
+ .lexxy-content__strikethrough.lexxy-content__underline {
59
+ text-decoration: line-through underline;
60
+ }
61
+
58
62
  mark,
59
63
  .lexxy-content__highlight {
60
64
  background-color: transparent;
@@ -419,16 +423,12 @@
419
423
  text-align: center;
420
424
 
421
425
  .attachment {
422
- display: inline-block;
426
+ display: inline-flex;
427
+ flex-direction: column;
428
+ gap: 0;
423
429
  inline-size: calc(33.333% - 0.8ch);
424
430
  vertical-align: top;
425
431
 
426
- img {
427
- margin: auto;
428
- max-block-size: 50rem;
429
- object-fit: contain;
430
- }
431
-
432
432
  .attachment__caption {
433
433
  padding-block-end: 1ch;
434
434
  }
@@ -451,10 +451,12 @@
451
451
  --lexxy-attachment-image-size: 1em;
452
452
  --lexxy-attachment-text-color: currentColor;
453
453
 
454
+ align-items: center;
454
455
  background: var(--lexxy-attachment-bg-color);
455
456
  border-radius: var(--lexxy-radius);
456
457
  color: var(--lexxy-attachment-text-color);
457
- display: inline;
458
+ display: inline-flex;
459
+ gap: 0.25ch;
458
460
  margin: 0;
459
461
  padding: 0;
460
462
  position: relative;
@@ -464,8 +466,6 @@
464
466
  block-size: var(--lexxy-attachment-image-size);
465
467
  border-radius: 50%;
466
468
  inline-size: var(--lexxy-attachment-image-size);
467
- margin-inline-end: 0.25ch;
468
- vertical-align: middle;
469
469
  }
470
470
  }
471
471
 
@@ -186,6 +186,8 @@
186
186
  }
187
187
 
188
188
  .attachment {
189
+ background-color: var(--lexxy-color-canvas);
190
+
189
191
  progress {
190
192
  max-inline-size: 10ch;
191
193
  margin: auto;
@@ -205,6 +207,93 @@
205
207
  inset-inline-start: unset;
206
208
  }
207
209
  }
210
+
211
+ &.attachment--error {
212
+ background: color-mix(var(--lexxy-color-red) 10%, transparent);
213
+ padding: 2ch;
214
+
215
+ &:before {
216
+ align-items: center;
217
+ aspect-ratio: 1;
218
+ background: var(--lexxy-color-red);
219
+ block-size: 1.5lh;
220
+ border-radius: 50%;
221
+ color: white;
222
+ content: "!";
223
+ display: flex;
224
+ justify-content: center;
225
+ margin: auto;
226
+ }
227
+
228
+ > div {
229
+ flex: 1;
230
+ font-size: 0.85em;
231
+ padding: 1ch;
232
+ text-align: start;
233
+ }
234
+ }
235
+ }
236
+
237
+ .attachment[draggable] {
238
+ cursor: grab;
239
+ }
240
+
241
+ .attachment.lexxy-dragging {
242
+ opacity: 0.4;
243
+ }
244
+
245
+ [class*="lexxy-drop-target--"] {
246
+ position: relative;
247
+ }
248
+
249
+ /* Horizontal line indicator for block and list drops */
250
+ .lexxy-drop-target--block-before::before,
251
+ .lexxy-drop-target--block-after::after,
252
+ .lexxy-drop-target--list-before::before,
253
+ .lexxy-drop-target--list-after::after {
254
+ background-color: var(--lexxy-focus-ring-color);
255
+ block-size: 3px;
256
+ border-radius: 1px;
257
+ content: "";
258
+ inset-inline: 0;
259
+ pointer-events: none;
260
+ position: absolute;
261
+ transform: translate(0, 0.5ch);
262
+ }
263
+
264
+ .lexxy-drop-target--block-before::before,
265
+ .lexxy-drop-target--list-before::before {
266
+ transform: translate(0, -0.5ch);
267
+ }
268
+
269
+ .lexxy-drop-target--block-before::before,
270
+ .lexxy-drop-target--list-before::before {
271
+ inset-block-start: -2px;
272
+ }
273
+
274
+ .lexxy-drop-target--block-after::after,
275
+ .lexxy-drop-target--list-after::after {
276
+ inset-block-end: -2px;
277
+ }
278
+
279
+ /* Vertical line indicator for gallery merge and reorder */
280
+ .lexxy-drop-target--gallery-before::before,
281
+ .lexxy-drop-target--gallery-after::after {
282
+ background-color: var(--lexxy-focus-ring-color);
283
+ border-radius: 1px;
284
+ content: "";
285
+ inset-block: 0;
286
+ inline-size: 3px;
287
+ pointer-events: none;
288
+ position: absolute;
289
+ }
290
+
291
+ .lexxy-drop-target--gallery-before::before {
292
+ inset-inline-start: -4px;
293
+ }
294
+
295
+ .lexxy-drop-target--gallery-after::after {
296
+ inset-inline-end: -4px;
208
297
  }
209
298
 
210
299
  .attachment:hover:not(.node--selected) {
@@ -225,27 +314,35 @@
225
314
  /* ------------------------------------------------------------------------ */
226
315
 
227
316
  .attachment-gallery {
317
+ --lexxy-attachment-gallery-columns: 3;
228
318
  --lexxy-attachment-gallery-gap: 0.4ch;
229
319
  --lexxy-focus-ring-offset: -6px;
230
320
 
231
- display: block;
232
321
  padding: 0;
233
322
 
234
323
  .attachment {
324
+ background: transparent;
325
+ box-sizing: border-box;
235
326
  margin: var(--lexxy-attachment-gallery-gap);
236
327
  padding: 0;
237
328
  padding-block-end: var(--lexxy-attachment-gap);
238
329
  vertical-align: top;
239
330
 
240
- &.attachment--error {
241
- padding: 2ch;
242
- }
243
-
244
331
  img {
245
332
  box-sizing: border-box;
246
333
  padding: 1ch;
247
334
  padding-block-end: 0;
248
335
  }
336
+
337
+
338
+ &.attachment--error {
339
+ background: color-mix(var(--lexxy-color-red) 10%, transparent);
340
+ padding: 2ch;
341
+
342
+ > div {
343
+ text-align: center;
344
+ }
345
+ }
249
346
  }
250
347
  }
251
348
 
@@ -339,7 +436,16 @@
339
436
  padding: 2px;
340
437
  position: relative;
341
438
 
342
- &[data-attachments="false"] button[name="upload"]{
439
+ &[data-attachments="false"] button[name="image"],
440
+ &[data-attachments="false"] button[name="file"] {
441
+ display: none;
442
+ }
443
+
444
+ &[data-upload="file"] button[name="image"] {
445
+ display: none;
446
+ }
447
+
448
+ &[data-upload="image"] button[name="file"] {
343
449
  display: none;
344
450
  }
345
451
 
@@ -406,6 +512,25 @@
406
512
  user-select: none;
407
513
  -webkit-user-select: none;
408
514
 
515
+ &.lexxy-editor__toolbar-dropdown--chevron {
516
+ summary {
517
+ aspect-ratio: unset;
518
+ gap: 0.5ch;
519
+ grid-template-columns: 2fr 1fr;
520
+ padding-inline: 0.75ch;
521
+
522
+ &:after {
523
+ block-size: 0.3ch;
524
+ border-block-end: 2px solid currentcolor;
525
+ border-inline-end: 2px solid currentcolor;
526
+ content: "";
527
+ display: inline-block;
528
+ inline-size: 0.3ch;
529
+ transform: rotate(45deg);
530
+ }
531
+ }
532
+ }
533
+
409
534
  summary ~ * {
410
535
  background-color: var(--lexxy-color-canvas);
411
536
  border: 2px solid var(--lexxy-color-selected-hover);
@@ -444,6 +569,44 @@
444
569
  }
445
570
  }
446
571
 
572
+ .lexxy-editor__toolbar-dropdown-list {
573
+ border-start-start-radius: 0;
574
+ flex-direction: column;
575
+ gap: 0.1ch;
576
+ padding: 0.1ch;
577
+
578
+ button {
579
+ align-items: center;
580
+ display: flex;
581
+ flex-direction: row;
582
+ gap: 1ch;
583
+ padding: 1ch;
584
+
585
+ &[aria-pressed="true"] {
586
+ background-color: var(--lexxy-color-selected);
587
+
588
+ &:hover {
589
+ background-color: var(--lexxy-color-selected-hover);
590
+ }
591
+ }
592
+
593
+ span {
594
+ font-size: var(--lexxy-text-small);
595
+ }
596
+
597
+ svg {
598
+ block-size: 1lh;
599
+ inline-size: 1lh;
600
+ }
601
+ }
602
+
603
+ .separator {
604
+ background: var(--lexxy-color-ink-lighter);
605
+ block-size: 1px;
606
+ inline-size: 100%;
607
+ }
608
+ }
609
+
447
610
 
448
611
  /* --------------------------------------------------------------------------
449
612
  /* Overflow menu */
@@ -905,7 +1068,6 @@ action-text-attachment[content-type^="application/vnd.actiontext"] {
905
1068
  }
906
1069
 
907
1070
  lexxy-node-delete-button {
908
- display: none;
909
1071
  inset-inline-start: 0;
910
1072
  line-height: 1lh;
911
1073
 
@@ -920,8 +1082,4 @@ action-text-attachment[content-type^="application/vnd.actiontext"] {
920
1082
  }
921
1083
  }
922
1084
  }
923
-
924
- &.node--selected lexxy-node-delete-button {
925
- display: block;
926
- }
927
1085
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@37signals/lexxy",
3
- "version": "0.8.2-beta",
3
+ "version": "0.8.6-beta",
4
4
  "description": "Lexxy - A modern rich text editor for Rails.",
5
5
  "module": "dist/lexxy.esm.js",
6
6
  "type": "module",