@livenetworks/ashlar 1.5.1 → 1.6.0
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 +189 -177
- package/js/COMPONENTS.md +55 -49
- package/js/index.js +22 -5
- package/js/ln-accordion/README.md +186 -137
- package/js/ln-accordion/ln-accordion.js +1 -1
- package/js/ln-ajax/README.md +73 -4
- package/js/ln-ajax/ln-ajax.js +1 -1
- package/js/ln-ajax/src/ln-ajax.js +13 -13
- package/js/ln-api-connector/README.md +261 -150
- package/js/ln-api-connector/ln-api-connector.js +1 -1
- package/js/ln-api-connector/src/ln-api-connector.js +368 -265
- package/js/ln-api-queue/README.md +219 -0
- package/js/ln-api-queue/ln-api-queue.js +1 -0
- package/js/ln-api-queue/src/ln-api-queue.js +320 -0
- package/js/ln-api-queue/src/queue-storage.js +455 -0
- package/js/ln-autoresize/README.md +47 -0
- package/js/ln-autoresize/ln-autoresize.js +1 -1
- package/js/ln-autosave/README.md +33 -1
- package/js/ln-autosave/ln-autosave.js +1 -1
- package/js/ln-autosave/src/ln-autosave.js +4 -4
- package/js/ln-circular-progress/README.md +27 -13
- package/js/ln-circular-progress/ln-circular-progress.js +1 -1
- package/js/ln-circular-progress/src/ln-circular-progress.js +18 -5
- package/js/ln-confirm/README.md +162 -86
- package/js/ln-confirm/_ln-confirm.scss +11 -0
- package/js/ln-confirm/ln-confirm.js +1 -1
- package/js/ln-confirm/src/ln-confirm.js +77 -41
- package/js/ln-core/README.md +896 -0
- package/js/ln-core/hash.js +92 -0
- package/js/ln-core/helpers.js +337 -8
- package/js/ln-core/index.js +4 -2
- package/js/ln-core/persist.js +29 -3
- package/js/ln-core/positioning.js +1 -35
- package/js/ln-core/window-cache.js +213 -0
- package/js/ln-couchdb-connector/README.md +177 -156
- package/js/ln-couchdb-connector/ln-couchdb-connector.js +1 -1
- package/js/ln-couchdb-connector/src/ln-couchdb-connector.js +398 -348
- package/js/ln-data-coordinator/README.md +536 -165
- package/js/ln-data-coordinator/ln-data-coordinator.js +1 -1
- package/js/ln-data-coordinator/src/data-read-policy.js +18 -0
- package/js/ln-data-coordinator/src/ln-data-coordinator.js +1042 -249
- package/js/ln-data-coordinator/src/mutation-receipts.js +46 -0
- package/js/ln-data-store/README.md +241 -94
- package/js/ln-data-store/ln-data-store.js +1 -1
- package/js/ln-data-store/src/ln-data-store.js +694 -699
- package/js/ln-date/README.md +39 -1
- package/js/ln-date/ln-date-dev.scss +14 -0
- package/js/ln-date/ln-date.js +1 -1
- package/js/ln-date/src/ln-date.js +236 -178
- package/js/ln-debug/README.md +58 -0
- package/js/ln-debug/ln-debug.js +1 -0
- package/js/ln-debug/src/ln-debug.js +19 -0
- package/js/ln-dropdown/README.md +31 -8
- package/js/ln-dropdown/ln-dropdown.js +1 -1
- package/js/ln-dropdown/src/ln-dropdown.js +30 -13
- package/js/ln-editor/README.md +274 -0
- package/js/ln-editor/ln-editor.js +1 -0
- package/js/ln-editor/ln-editor.scss +43 -0
- package/js/ln-editor/src/ln-editor.js +619 -0
- package/js/ln-external-links/README.md +44 -5
- package/js/ln-external-links/ln-external-links.js +1 -1
- package/js/ln-fill/README.md +199 -0
- package/js/ln-fill/ln-fill.js +1 -0
- package/js/ln-fill/src/ln-fill.js +87 -0
- package/js/ln-filter/README.md +92 -2
- package/js/ln-filter/ln-filter-dev.scss +8 -0
- package/js/ln-filter/ln-filter.js +1 -1
- package/js/ln-filter/src/ln-filter.js +22 -7
- package/js/ln-form/README.md +212 -49
- package/js/ln-form/ln-form.js +1 -1
- package/js/ln-form/src/ln-form.js +99 -199
- package/js/ln-http/README.md +125 -89
- package/js/ln-http/ln-http.js +1 -1
- package/js/ln-http/src/ln-http.js +32 -13
- package/js/ln-icon/README.md +130 -0
- package/js/ln-icon/ln-icon.js +1 -0
- package/js/{ln-icons/src/ln-icons.js → ln-icon/src/ln-icon.js} +20 -15
- package/js/ln-link/README.md +47 -9
- package/js/ln-link/ln-link.js +1 -1
- package/js/ln-link/src/ln-link.js +22 -6
- package/js/ln-list/README.md +129 -0
- package/js/ln-list/ln-list.js +1 -0
- package/js/ln-list/ln-list.scss +28 -0
- package/js/ln-list/src/ln-list.js +1216 -0
- package/js/ln-modal/README.md +185 -154
- package/js/ln-modal/ln-modal-dev.scss +13 -0
- package/js/ln-modal/ln-modal.js +1 -1
- package/js/ln-modal/ln-modal.scss +28 -4
- package/js/ln-modal/src/ln-modal.js +137 -201
- package/js/ln-modal-coordinator/README.md +99 -0
- package/js/ln-modal-coordinator/ln-modal-coordinator-dev.scss +8 -0
- package/js/ln-modal-coordinator/ln-modal-coordinator.js +1 -0
- package/js/ln-modal-coordinator/src/ln-modal-coordinator.js +345 -0
- package/js/ln-nav/README.md +46 -3
- package/js/ln-nav/ln-nav.js +1 -1
- package/js/ln-nav/src/ln-nav.js +92 -127
- package/js/ln-number/README.md +28 -1
- package/js/ln-number/ln-number-dev.scss +13 -0
- package/js/ln-number/ln-number.js +1 -1
- package/js/ln-number/src/ln-number.js +443 -350
- package/js/ln-options/README.md +66 -0
- package/js/ln-options/ln-options.js +1 -0
- package/js/ln-options/src/ln-options.js +74 -0
- package/js/ln-popover/README.md +40 -5
- package/js/ln-popover/ln-popover.js +1 -1
- package/js/ln-popover/src/ln-popover.js +38 -11
- package/js/ln-progress/README.md +413 -442
- package/js/ln-progress/ln-progress.js +1 -1
- package/js/ln-progress/src/ln-progress.js +98 -150
- package/js/ln-router/README.md +432 -0
- package/js/ln-router/ln-router.js +1 -0
- package/js/ln-router/src/ln-router.js +539 -0
- package/js/ln-search/README.md +158 -83
- package/js/ln-search/ln-search-dev.scss +8 -0
- package/js/ln-search/ln-search.js +1 -1
- package/js/ln-search/src/ln-search.js +12 -9
- package/js/ln-slug/README.md +110 -0
- package/js/ln-slug/ln-slug.js +1 -0
- package/js/ln-slug/src/ln-slug.js +74 -0
- package/js/ln-sort/README.md +170 -0
- package/js/ln-sort/ln-sort-dev.scss +8 -0
- package/js/ln-sort/ln-sort.js +1 -0
- package/js/ln-sort/src/ln-sort.js +178 -0
- package/js/ln-sortable/README.md +133 -95
- package/js/ln-sortable/ln-sortable.js +1 -1
- package/js/ln-stat/README.md +55 -0
- package/js/ln-stat/ln-stat.js +1 -0
- package/js/ln-stat/src/ln-stat.js +55 -0
- package/js/ln-table/README.md +285 -101
- package/js/ln-table/ln-table-dev.scss +17 -0
- package/js/ln-table/ln-table.js +1 -1
- package/js/ln-table/ln-table.scss +1 -1
- package/js/ln-table/src/ln-table.js +1493 -473
- package/js/ln-table-coordinator/README.md +73 -0
- package/js/ln-table-coordinator/ln-table-coordinator.js +1 -0
- package/js/ln-table-coordinator/src/ln-table-coordinator.js +160 -0
- package/js/ln-tabs/README.md +221 -137
- package/js/ln-tabs/ln-tabs-dev.scss +18 -0
- package/js/ln-tabs/ln-tabs.js +1 -1
- package/js/ln-tabs/src/ln-tabs.js +41 -24
- package/js/ln-time/README.md +14 -0
- package/js/ln-time/ln-time.js +1 -1
- package/js/ln-time/src/ln-time.js +223 -192
- package/js/ln-toast/README.md +178 -122
- package/js/ln-toast/ln-toast.js +1 -15
- package/js/ln-toast/src/ln-toast.js +232 -210
- package/js/ln-toast/template.html +16 -12
- package/js/ln-toggle/README.md +61 -2
- package/js/ln-toggle/ln-toggle-dev.scss +13 -0
- package/js/ln-toggle/ln-toggle.js +1 -1
- package/js/ln-toggle/src/ln-toggle.js +56 -43
- package/js/ln-tooltip/README.md +95 -58
- package/js/ln-tooltip/ln-tooltip-dev.scss +9 -0
- package/js/ln-tooltip/ln-tooltip.js +1 -1
- package/js/ln-tooltip/src/ln-tooltip.js +45 -6
- package/js/ln-translations/README.md +118 -96
- package/js/ln-translations/ln-translations.js +1 -1
- package/js/ln-translations/src/ln-translations.js +10 -5
- package/js/ln-upload/README.md +85 -10
- package/js/ln-upload/ln-upload.js +1 -1
- package/js/ln-upload/src/ln-upload.js +26 -10
- package/js/ln-validate/README.md +72 -0
- package/js/ln-validate/ln-validate-dev.scss +15 -0
- package/js/ln-validate/ln-validate.js +1 -1
- package/js/ln-validate/src/ln-validate.js +64 -0
- package/package.json +61 -56
- package/scss/base/_global.scss +2 -2
- package/scss/base/_typography.scss +1 -2
- package/scss/components/_date.scss +1 -0
- package/scss/components/_dropdown.scss +0 -5
- package/scss/components/_editor.scss +5 -0
- package/scss/components/_filter.scss +26 -25
- package/scss/components/_form.scss +7 -0
- package/scss/components/_ln-table.scss +13 -3
- package/scss/components/_sort.scss +10 -0
- package/scss/components/_table.scss +36 -23
- package/scss/components/_toast.scss +68 -41
- package/scss/components/_toggle.scss +16 -0
- package/scss/config/_density.scss +205 -117
- package/scss/config/_icons.scss +10 -9
- package/scss/config/_tokens.scss +69 -47
- package/scss/config/mixins/_accordion.scss +2 -7
- package/scss/config/mixins/_alert.scss +4 -4
- package/scss/config/mixins/_breadcrumbs.scss +2 -2
- package/scss/config/mixins/_btn.scss +1 -1
- package/scss/config/mixins/_card.scss +89 -14
- package/scss/config/mixins/_collapsible.scss +3 -0
- package/scss/config/mixins/_colors.scss +1 -1
- package/scss/config/mixins/_diagnostics.scss +11 -0
- package/scss/config/mixins/_display.scss +1 -0
- package/scss/config/mixins/_dropdown.scss +3 -5
- package/scss/config/mixins/_editor.scss +124 -0
- package/scss/config/mixins/_empty-state.scss +7 -0
- package/scss/config/mixins/_filter.scss +3 -5
- package/scss/config/mixins/_footer.scss +1 -2
- package/scss/config/mixins/_form.scss +207 -21
- package/scss/config/mixins/_index.scss +2 -0
- package/scss/config/mixins/_ln-table.scss +41 -2
- package/scss/config/mixins/_modal.scss +149 -66
- package/scss/config/mixins/_nav.scss +3 -4
- package/scss/config/mixins/_popover.scss +39 -0
- package/scss/config/mixins/_sidebar.scss +1 -1
- package/scss/config/mixins/_sort.scss +42 -0
- package/scss/config/mixins/_table.scss +85 -60
- package/scss/config/mixins/_toast.scss +179 -152
- package/scss/config/mixins/_tooltip.scss +17 -7
- package/scss/config/mixins/_typography.scss +4 -2
- package/scss/ln-ashlar-dev.scss +27 -9
- package/scss/ln-ashlar.scss +7 -1
- package/js/ln-data-table/README.md +0 -110
- package/js/ln-data-table/ln-data-table.js +0 -1
- package/js/ln-data-table/ln-data-table.scss +0 -10
- package/js/ln-data-table/src/ln-data-table.js +0 -1087
- package/js/ln-icons/README.md +0 -88
- package/js/ln-icons/ln-icons.js +0 -1
- package/js/ln-table/ln-table-sort.js +0 -1
- package/js/ln-table/src/ln-table-sort.js +0 -156
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
import { dispatch, dispatchCancelable, registerComponent, cloneTemplateScoped } from '../../ln-core';
|
|
2
|
+
|
|
3
|
+
(function () {
|
|
4
|
+
const DOM_SELECTOR = 'data-ln-editor';
|
|
5
|
+
const DOM_ATTRIBUTE = 'lnEditor';
|
|
6
|
+
|
|
7
|
+
if (window[DOM_ATTRIBUTE] !== undefined) return;
|
|
8
|
+
|
|
9
|
+
// ─── Allowed tags for paste sanitization ───────────────────
|
|
10
|
+
const ALLOWED_TAGS = {
|
|
11
|
+
P: true, BR: true, STRONG: true, B: true, EM: true, I: true,
|
|
12
|
+
U: true, S: true, A: true, UL: true, OL: true, LI: true,
|
|
13
|
+
H2: true, H3: true, H4: true, BLOCKQUOTE: true, PRE: true,
|
|
14
|
+
CODE: true, DIV: true
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// ─── Action → execCommand mapping ─────────────────────────
|
|
18
|
+
const INLINE_COMMANDS = {
|
|
19
|
+
'bold': 'bold',
|
|
20
|
+
'italic': 'italic',
|
|
21
|
+
'underline': 'underline',
|
|
22
|
+
'strikethrough': 'strikeThrough'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const BLOCK_COMMANDS = {
|
|
26
|
+
'heading-2': 'h2',
|
|
27
|
+
'heading-3': 'h3',
|
|
28
|
+
'heading-4': 'h4',
|
|
29
|
+
'blockquote': 'blockquote',
|
|
30
|
+
'code': 'pre',
|
|
31
|
+
'paragraph': 'p'
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const LIST_COMMANDS = {
|
|
35
|
+
'ordered-list': 'insertOrderedList',
|
|
36
|
+
'unordered-list': 'insertUnorderedList'
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// Monotonic id source for surfaces whose textarea has no id
|
|
40
|
+
let _uid = 0;
|
|
41
|
+
|
|
42
|
+
// Toggle-able formatting actions get aria-pressed; one-shot actions
|
|
43
|
+
// (unlink, clear, confirm-link, cancel-link) do not.
|
|
44
|
+
function _isToggleAction(action) {
|
|
45
|
+
return !!(INLINE_COMMANDS[action] || BLOCK_COMMANDS[action] ||
|
|
46
|
+
LIST_COMMANDS[action] || action === 'link');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ─── Component ────────────────────────────────────────────
|
|
50
|
+
function _component(dom) {
|
|
51
|
+
this.dom = dom;
|
|
52
|
+
const self = this;
|
|
53
|
+
|
|
54
|
+
// Find the textarea inside the container
|
|
55
|
+
this._textarea = dom.querySelector('textarea');
|
|
56
|
+
if (!this._textarea) {
|
|
57
|
+
console.warn('[ln-editor] No <textarea> found inside', dom);
|
|
58
|
+
return this;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Read placeholder from textarea
|
|
62
|
+
const placeholder = this._textarea.getAttribute('placeholder') || '';
|
|
63
|
+
|
|
64
|
+
// Mark textarea as source (CSS hides it)
|
|
65
|
+
this._textarea.setAttribute('data-ln-editor-source', '');
|
|
66
|
+
|
|
67
|
+
// Create the editing surface
|
|
68
|
+
this._surface = document.createElement('div');
|
|
69
|
+
this._surface.className = 'ln-editor__surface';
|
|
70
|
+
this._surface.setAttribute('contenteditable', 'true');
|
|
71
|
+
this._surface.setAttribute('role', 'textbox');
|
|
72
|
+
this._surface.setAttribute('aria-multiline', 'true');
|
|
73
|
+
if (placeholder) {
|
|
74
|
+
this._surface.setAttribute('data-placeholder', placeholder);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Transfer textarea label association via aria-labelledby
|
|
78
|
+
const textareaId = this._textarea.id;
|
|
79
|
+
if (textareaId) {
|
|
80
|
+
const label = dom.querySelector('label[for="' + textareaId + '"]');
|
|
81
|
+
if (label) {
|
|
82
|
+
if (!label.id) label.id = textareaId + '-label';
|
|
83
|
+
this._surface.setAttribute('aria-labelledby', label.id);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Stable id so the toolbar can reference the surface via aria-controls
|
|
88
|
+
this._surface.id = textareaId ? textareaId + '-surface' : 'ln-editor-surface-' + (++_uid);
|
|
89
|
+
|
|
90
|
+
// Set initial content from textarea value
|
|
91
|
+
const initialHtml = this._textarea.value.trim();
|
|
92
|
+
if (initialHtml) {
|
|
93
|
+
this._surface.innerHTML = initialHtml;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Insert the surface into the DOM (after the toolbar, or at end)
|
|
97
|
+
const toolbar = dom.querySelector('[role="toolbar"]');
|
|
98
|
+
if (toolbar && toolbar.nextSibling) {
|
|
99
|
+
dom.insertBefore(this._surface, toolbar.nextSibling);
|
|
100
|
+
} else {
|
|
101
|
+
dom.appendChild(this._surface);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Wire toolbar → surface relationship and seed toggle-button states
|
|
105
|
+
if (toolbar) {
|
|
106
|
+
toolbar.setAttribute('aria-controls', this._surface.id);
|
|
107
|
+
const _tbBtns = toolbar.querySelectorAll('[data-ln-editor-action]');
|
|
108
|
+
for (let _b = 0; _b < _tbBtns.length; _b++) {
|
|
109
|
+
const _act = _tbBtns[_b].getAttribute('data-ln-editor-action');
|
|
110
|
+
if (_isToggleAction(_act)) _tbBtns[_b].setAttribute('aria-pressed', 'false');
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ─── Event handlers (bound references for cleanup) ────
|
|
115
|
+
this._onInput = function () {
|
|
116
|
+
self._syncToTextarea();
|
|
117
|
+
dispatch(self.dom, 'ln-editor:changed', {
|
|
118
|
+
html: self._textarea.value,
|
|
119
|
+
target: self.dom
|
|
120
|
+
});
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
this._onMousedownToolbar = function (e) {
|
|
124
|
+
// Prevent toolbar click from stealing focus from surface
|
|
125
|
+
const btn = e.target.closest('[data-ln-editor-action]');
|
|
126
|
+
if (btn) e.preventDefault();
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
this._onClickToolbar = function (e) {
|
|
130
|
+
const btn = e.target.closest('[data-ln-editor-action]');
|
|
131
|
+
if (!btn) return;
|
|
132
|
+
const action = btn.getAttribute('data-ln-editor-action');
|
|
133
|
+
self._execAction(action);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
this._onPaste = function (e) {
|
|
137
|
+
_handlePaste(self, e);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
this._onKeydown = function (e) {
|
|
141
|
+
_handleKeydown(self, e);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
this._onSelectionChange = function () {
|
|
145
|
+
// Document-level listener: no-op when the surface has been removed
|
|
146
|
+
// from the DOM without destroy() (e.g. SPA subtree swap). The
|
|
147
|
+
// listener is intentionally NOT removed here so a temporarily
|
|
148
|
+
// detached-then-reattached surface keeps working.
|
|
149
|
+
if (!document.contains(self._surface)) return;
|
|
150
|
+
self._updateActiveStates();
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
this._onFocus = function () {
|
|
154
|
+
dispatch(self.dom, 'ln-editor:focus', { target: self.dom });
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
this._onBlur = function () {
|
|
158
|
+
// Sync on blur to ensure textarea is up-to-date
|
|
159
|
+
self._syncToTextarea();
|
|
160
|
+
dispatch(self.dom, 'ln-editor:blur', { target: self.dom });
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// ─── Bind events ──────────────────────────────────────
|
|
164
|
+
this._surface.addEventListener('input', this._onInput);
|
|
165
|
+
this._surface.addEventListener('paste', this._onPaste);
|
|
166
|
+
this._surface.addEventListener('keydown', this._onKeydown);
|
|
167
|
+
this._surface.addEventListener('focus', this._onFocus);
|
|
168
|
+
this._surface.addEventListener('blur', this._onBlur);
|
|
169
|
+
|
|
170
|
+
if (toolbar) {
|
|
171
|
+
toolbar.addEventListener('mousedown', this._onMousedownToolbar);
|
|
172
|
+
toolbar.addEventListener('click', this._onClickToolbar);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
document.addEventListener('selectionchange', this._onSelectionChange);
|
|
176
|
+
|
|
177
|
+
// Request event — set content programmatically
|
|
178
|
+
this._onSetContent = function (e) {
|
|
179
|
+
const html = e.detail && e.detail.html;
|
|
180
|
+
if (html !== undefined) {
|
|
181
|
+
// Direct innerHTML set fires no native input — dispatch manually.
|
|
182
|
+
self._surface.innerHTML = html;
|
|
183
|
+
self._syncToTextarea();
|
|
184
|
+
dispatch(self.dom, 'ln-editor:changed', {
|
|
185
|
+
html: self._textarea.value,
|
|
186
|
+
target: self.dom
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
dom.addEventListener('ln-editor:set-content', this._onSetContent);
|
|
191
|
+
|
|
192
|
+
// Handle parent form reset
|
|
193
|
+
const form = this._textarea.form;
|
|
194
|
+
if (form) {
|
|
195
|
+
this._onFormReset = function () {
|
|
196
|
+
setTimeout(function () {
|
|
197
|
+
self._surface.innerHTML = self._textarea.value;
|
|
198
|
+
dispatch(dom, 'ln-editor:changed', {
|
|
199
|
+
html: self._textarea.value,
|
|
200
|
+
target: dom
|
|
201
|
+
});
|
|
202
|
+
}, 0);
|
|
203
|
+
};
|
|
204
|
+
form.addEventListener('reset', this._onFormReset);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return this;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ─── Prototype Methods ────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
_component.prototype._syncToTextarea = function () {
|
|
213
|
+
if (!this._textarea) return;
|
|
214
|
+
this._textarea.value = this._surface.innerHTML;
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
_component.prototype._execAction = function (action) {
|
|
218
|
+
if (!action) return;
|
|
219
|
+
|
|
220
|
+
const before = dispatchCancelable(this.dom, 'ln-editor:before-change', {
|
|
221
|
+
action: action,
|
|
222
|
+
target: this.dom
|
|
223
|
+
});
|
|
224
|
+
if (before.defaultPrevented) return;
|
|
225
|
+
|
|
226
|
+
// Ensure focus is on the surface
|
|
227
|
+
this._surface.focus();
|
|
228
|
+
|
|
229
|
+
if (INLINE_COMMANDS[action]) {
|
|
230
|
+
document.execCommand(INLINE_COMMANDS[action], false, null);
|
|
231
|
+
} else if (BLOCK_COMMANDS[action]) {
|
|
232
|
+
// Toggle: if current block matches, revert to <p>
|
|
233
|
+
const tag = BLOCK_COMMANDS[action];
|
|
234
|
+
const current = _getActiveBlockTag(this._surface);
|
|
235
|
+
if (current && current.toLowerCase() === tag) {
|
|
236
|
+
document.execCommand('formatBlock', false, '<p>');
|
|
237
|
+
} else {
|
|
238
|
+
document.execCommand('formatBlock', false, '<' + tag + '>');
|
|
239
|
+
}
|
|
240
|
+
} else if (LIST_COMMANDS[action]) {
|
|
241
|
+
document.execCommand(LIST_COMMANDS[action], false, null);
|
|
242
|
+
} else if (action === 'link') {
|
|
243
|
+
_handleLink(this);
|
|
244
|
+
} else if (action === 'unlink') {
|
|
245
|
+
document.execCommand('unlink', false, null);
|
|
246
|
+
} else if (action === 'clear') {
|
|
247
|
+
document.execCommand('removeFormat', false, null);
|
|
248
|
+
document.execCommand('formatBlock', false, '<p>');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// execCommand-driven edits fire a native input → _onInput dispatches
|
|
252
|
+
// 'changed'. Do NOT dispatch here (was A1 double-fire). The 'link'
|
|
253
|
+
// action opens a popover and mutates nothing until _apply.
|
|
254
|
+
this._syncToTextarea();
|
|
255
|
+
this._updateActiveStates();
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
_component.prototype._updateActiveStates = function () {
|
|
259
|
+
const toolbar = this.dom.querySelector('[role="toolbar"]');
|
|
260
|
+
if (!toolbar) return;
|
|
261
|
+
|
|
262
|
+
// Only update if the selection is within our surface
|
|
263
|
+
const sel = window.getSelection();
|
|
264
|
+
if (!sel || sel.rangeCount === 0) return;
|
|
265
|
+
const anchorNode = sel.anchorNode;
|
|
266
|
+
if (!anchorNode || !this._surface.contains(anchorNode)) return;
|
|
267
|
+
|
|
268
|
+
const buttons = toolbar.querySelectorAll('[data-ln-editor-action]');
|
|
269
|
+
for (let i = 0; i < buttons.length; i++) {
|
|
270
|
+
const btn = buttons[i];
|
|
271
|
+
const action = btn.getAttribute('data-ln-editor-action');
|
|
272
|
+
let isActive = false;
|
|
273
|
+
|
|
274
|
+
if (INLINE_COMMANDS[action]) {
|
|
275
|
+
try {
|
|
276
|
+
isActive = document.queryCommandState(INLINE_COMMANDS[action]);
|
|
277
|
+
} catch (e) {
|
|
278
|
+
// Ignore — some commands may not be queryable
|
|
279
|
+
}
|
|
280
|
+
} else if (BLOCK_COMMANDS[action]) {
|
|
281
|
+
const currentBlock = _getActiveBlockTag(this._surface);
|
|
282
|
+
isActive = currentBlock && currentBlock.toLowerCase() === BLOCK_COMMANDS[action];
|
|
283
|
+
} else if (LIST_COMMANDS[action]) {
|
|
284
|
+
try {
|
|
285
|
+
isActive = document.queryCommandState(LIST_COMMANDS[action]);
|
|
286
|
+
} catch (e) {
|
|
287
|
+
// Ignore
|
|
288
|
+
}
|
|
289
|
+
} else if (action === 'link') {
|
|
290
|
+
// Check if selection is inside an <a>
|
|
291
|
+
isActive = !!_getAncestorTag(sel.anchorNode, 'A', this._surface);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if (_isToggleAction(action)) {
|
|
295
|
+
btn.setAttribute('aria-pressed', String(isActive));
|
|
296
|
+
}
|
|
297
|
+
if (isActive) {
|
|
298
|
+
btn.classList.add('ln-editor-active');
|
|
299
|
+
} else {
|
|
300
|
+
btn.classList.remove('ln-editor-active');
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
_component.prototype.getHTML = function () {
|
|
306
|
+
return this._surface ? this._surface.innerHTML : '';
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
_component.prototype.setHTML = function (html) {
|
|
310
|
+
if (!this._surface) return;
|
|
311
|
+
// Direct innerHTML set fires no native input — dispatch manually.
|
|
312
|
+
this._surface.innerHTML = html;
|
|
313
|
+
this._syncToTextarea();
|
|
314
|
+
dispatch(this.dom, 'ln-editor:changed', {
|
|
315
|
+
html: this._textarea.value,
|
|
316
|
+
target: this.dom
|
|
317
|
+
});
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
_component.prototype.destroy = function () {
|
|
321
|
+
if (!this.dom[DOM_ATTRIBUTE]) return;
|
|
322
|
+
|
|
323
|
+
// Remove event listeners
|
|
324
|
+
if (this._surface) {
|
|
325
|
+
this._surface.removeEventListener('input', this._onInput);
|
|
326
|
+
this._surface.removeEventListener('paste', this._onPaste);
|
|
327
|
+
this._surface.removeEventListener('keydown', this._onKeydown);
|
|
328
|
+
this._surface.removeEventListener('focus', this._onFocus);
|
|
329
|
+
this._surface.removeEventListener('blur', this._onBlur);
|
|
330
|
+
this._surface.remove();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const toolbar = this.dom.querySelector('[role="toolbar"]');
|
|
334
|
+
if (toolbar) {
|
|
335
|
+
toolbar.removeEventListener('mousedown', this._onMousedownToolbar);
|
|
336
|
+
toolbar.removeEventListener('click', this._onClickToolbar);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
document.removeEventListener('selectionchange', this._onSelectionChange);
|
|
340
|
+
this.dom.removeEventListener('ln-editor:set-content', this._onSetContent);
|
|
341
|
+
|
|
342
|
+
const form = this._textarea ? this._textarea.form : null;
|
|
343
|
+
if (form && this._onFormReset) {
|
|
344
|
+
form.removeEventListener('reset', this._onFormReset);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Restore textarea visibility
|
|
348
|
+
if (this._textarea) {
|
|
349
|
+
this._textarea.removeAttribute('data-ln-editor-source');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Remove link popover if present
|
|
353
|
+
const popover = this.dom.querySelector('.ln-editor__link-popover');
|
|
354
|
+
if (popover) popover.remove();
|
|
355
|
+
|
|
356
|
+
dispatch(this.dom, 'ln-editor:destroyed', { target: this.dom });
|
|
357
|
+
delete this.dom[DOM_ATTRIBUTE];
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// ─── Private helpers ──────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Walk up from node to find the active block-level tag within the surface.
|
|
364
|
+
*/
|
|
365
|
+
function _getActiveBlockTag(surface) {
|
|
366
|
+
const sel = window.getSelection();
|
|
367
|
+
if (!sel || sel.rangeCount === 0) return null;
|
|
368
|
+
|
|
369
|
+
let node = sel.anchorNode;
|
|
370
|
+
if (!node) return null;
|
|
371
|
+
|
|
372
|
+
// Walk up to find block element
|
|
373
|
+
while (node && node !== surface) {
|
|
374
|
+
if (node.nodeType === 1) {
|
|
375
|
+
const tag = node.tagName;
|
|
376
|
+
if (tag === 'H2' || tag === 'H3' || tag === 'H4' ||
|
|
377
|
+
tag === 'BLOCKQUOTE' || tag === 'PRE' || tag === 'P') {
|
|
378
|
+
return tag;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
node = node.parentNode;
|
|
382
|
+
}
|
|
383
|
+
return null;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Walk up from node to find an ancestor with the given tag.
|
|
388
|
+
*/
|
|
389
|
+
function _getAncestorTag(node, tagName, boundary) {
|
|
390
|
+
while (node && node !== boundary) {
|
|
391
|
+
if (node.nodeType === 1 && node.tagName === tagName) {
|
|
392
|
+
return node;
|
|
393
|
+
}
|
|
394
|
+
node = node.parentNode;
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Sanitize pasted HTML — strip disallowed tags and attributes.
|
|
401
|
+
*/
|
|
402
|
+
function _handlePaste(instance, e) {
|
|
403
|
+
e.preventDefault();
|
|
404
|
+
|
|
405
|
+
let html = '';
|
|
406
|
+
if (e.clipboardData) {
|
|
407
|
+
html = e.clipboardData.getData('text/html');
|
|
408
|
+
if (!html) {
|
|
409
|
+
// Fallback to plain text
|
|
410
|
+
const text = e.clipboardData.getData('text/plain');
|
|
411
|
+
if (text) {
|
|
412
|
+
// Convert line breaks to <br> for plain text paste
|
|
413
|
+
html = text
|
|
414
|
+
.replace(/&/g, '&')
|
|
415
|
+
.replace(/</g, '<')
|
|
416
|
+
.replace(/>/g, '>')
|
|
417
|
+
.replace(/\n\n/g, '</p><p>')
|
|
418
|
+
.replace(/\n/g, '<br>');
|
|
419
|
+
html = '<p>' + html + '</p>';
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (!html) return;
|
|
425
|
+
|
|
426
|
+
const sanitized = _sanitizeHTML(html);
|
|
427
|
+
if (sanitized) {
|
|
428
|
+
document.execCommand('insertHTML', false, sanitized);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Recursively sanitize an HTML string, keeping only allowed tags.
|
|
434
|
+
*/
|
|
435
|
+
function _sanitizeHTML(html) {
|
|
436
|
+
const container = document.createElement('div');
|
|
437
|
+
container.innerHTML = html;
|
|
438
|
+
_walkAndSanitize(container);
|
|
439
|
+
return container.innerHTML;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function _walkAndSanitize(node) {
|
|
443
|
+
const children = Array.from(node.childNodes);
|
|
444
|
+
for (let i = 0; i < children.length; i++) {
|
|
445
|
+
const child = children[i];
|
|
446
|
+
|
|
447
|
+
// Keep text nodes
|
|
448
|
+
if (child.nodeType === 3) continue;
|
|
449
|
+
|
|
450
|
+
// Remove non-element nodes (comments, etc.)
|
|
451
|
+
if (child.nodeType !== 1) {
|
|
452
|
+
node.removeChild(child);
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (!ALLOWED_TAGS[child.tagName]) {
|
|
457
|
+
// Replace disallowed tag with its children (unwrap)
|
|
458
|
+
while (child.firstChild) {
|
|
459
|
+
node.insertBefore(child.firstChild, child);
|
|
460
|
+
}
|
|
461
|
+
node.removeChild(child);
|
|
462
|
+
} else {
|
|
463
|
+
// Strip all attributes except href on <a>
|
|
464
|
+
const attrs = Array.from(child.attributes);
|
|
465
|
+
for (let j = 0; j < attrs.length; j++) {
|
|
466
|
+
const attrName = attrs[j].name;
|
|
467
|
+
if (child.tagName === 'A' && attrName === 'href') {
|
|
468
|
+
// Sanitize href — only allow http/https/mailto
|
|
469
|
+
const href = child.getAttribute('href') || '';
|
|
470
|
+
if (!/^(https?:|mailto:|\/|#)/.test(href)) {
|
|
471
|
+
child.removeAttribute('href');
|
|
472
|
+
}
|
|
473
|
+
} else {
|
|
474
|
+
child.removeAttribute(attrName);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Ensure links open safely
|
|
479
|
+
if (child.tagName === 'A') {
|
|
480
|
+
child.setAttribute('rel', 'noopener noreferrer');
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Recurse
|
|
484
|
+
_walkAndSanitize(child);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Handle keyboard shortcuts.
|
|
491
|
+
*/
|
|
492
|
+
function _handleKeydown(instance, e) {
|
|
493
|
+
if (!(e.ctrlKey || e.metaKey)) return;
|
|
494
|
+
|
|
495
|
+
let action = null;
|
|
496
|
+
switch (e.key.toLowerCase()) {
|
|
497
|
+
case 'b': action = 'bold'; break;
|
|
498
|
+
case 'i': action = 'italic'; break;
|
|
499
|
+
case 'u': action = 'underline'; break;
|
|
500
|
+
case 'k': action = 'link'; break;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (action) {
|
|
504
|
+
e.preventDefault();
|
|
505
|
+
instance._execAction(action);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Handle link insertion — inline popover approach.
|
|
511
|
+
* Creates a small URL input inline at the toolbar level.
|
|
512
|
+
*/
|
|
513
|
+
function _handleLink(instance) {
|
|
514
|
+
const sel = window.getSelection();
|
|
515
|
+
if (!sel || sel.rangeCount === 0) return;
|
|
516
|
+
|
|
517
|
+
// Check if already inside a link — if so, let user edit the href
|
|
518
|
+
const existingLink = _getAncestorTag(sel.anchorNode, 'A', instance._surface);
|
|
519
|
+
|
|
520
|
+
// Save the current selection range so we can restore it
|
|
521
|
+
const savedRange = sel.getRangeAt(0).cloneRange();
|
|
522
|
+
|
|
523
|
+
// Remove any existing popover
|
|
524
|
+
const existing = instance.dom.querySelector('.ln-editor__link-popover');
|
|
525
|
+
if (existing) existing.remove();
|
|
526
|
+
|
|
527
|
+
// Clone popover template
|
|
528
|
+
const fragment = cloneTemplateScoped(instance.dom, 'ln-editor-link-popover', 'ln-editor');
|
|
529
|
+
if (!fragment) return;
|
|
530
|
+
const popover = fragment.firstElementChild;
|
|
531
|
+
if (!popover) return;
|
|
532
|
+
|
|
533
|
+
const input = popover.querySelector('input[type="url"]');
|
|
534
|
+
const confirmBtn = popover.querySelector('[data-ln-editor-action="confirm-link"]');
|
|
535
|
+
const removeBtn = popover.querySelector('[data-ln-editor-action="cancel-link"]');
|
|
536
|
+
|
|
537
|
+
if (existingLink) {
|
|
538
|
+
input.value = existingLink.getAttribute('href') || '';
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// Insert popover after the toolbar
|
|
542
|
+
const toolbar = instance.dom.querySelector('[role="toolbar"]');
|
|
543
|
+
if (toolbar) {
|
|
544
|
+
toolbar.after(popover);
|
|
545
|
+
} else {
|
|
546
|
+
instance.dom.insertBefore(popover, instance._surface);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
input.focus();
|
|
550
|
+
|
|
551
|
+
function _restoreSelection() {
|
|
552
|
+
const sel = window.getSelection();
|
|
553
|
+
sel.removeAllRanges();
|
|
554
|
+
sel.addRange(savedRange);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function _apply() {
|
|
558
|
+
const url = input.value.trim();
|
|
559
|
+
popover.remove();
|
|
560
|
+
|
|
561
|
+
_restoreSelection();
|
|
562
|
+
instance._surface.focus();
|
|
563
|
+
|
|
564
|
+
if (url) {
|
|
565
|
+
if (existingLink) {
|
|
566
|
+
// Bare setAttribute — no execCommand, so no native input
|
|
567
|
+
// fires. Sync + dispatch 'changed' exactly once here.
|
|
568
|
+
existingLink.setAttribute('href', url);
|
|
569
|
+
existingLink.setAttribute('rel', 'noopener noreferrer');
|
|
570
|
+
instance._syncToTextarea();
|
|
571
|
+
dispatch(instance.dom, 'ln-editor:changed', {
|
|
572
|
+
html: instance._textarea.value,
|
|
573
|
+
target: instance.dom
|
|
574
|
+
});
|
|
575
|
+
} else {
|
|
576
|
+
// createLink is an execCommand → native input → _onInput
|
|
577
|
+
// syncs + dispatches 'changed'. Do NOT dispatch again.
|
|
578
|
+
document.execCommand('createLink', false, url);
|
|
579
|
+
const sel = window.getSelection();
|
|
580
|
+
if (sel && sel.anchorNode) {
|
|
581
|
+
const newLink = _getAncestorTag(sel.anchorNode, 'A', instance._surface);
|
|
582
|
+
if (newLink) {
|
|
583
|
+
// rel lands after _onInput already captured innerHTML —
|
|
584
|
+
// silently re-sync the textarea, no second dispatch.
|
|
585
|
+
newLink.setAttribute('rel', 'noopener noreferrer');
|
|
586
|
+
instance._syncToTextarea();
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
} else if (existingLink) {
|
|
591
|
+
// unlink is an execCommand → native input → _onInput dispatches.
|
|
592
|
+
document.execCommand('unlink', false, null);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function _cancel() {
|
|
597
|
+
popover.remove();
|
|
598
|
+
_restoreSelection();
|
|
599
|
+
instance._surface.focus();
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
confirmBtn.addEventListener('click', _apply);
|
|
603
|
+
removeBtn.addEventListener('click', _cancel);
|
|
604
|
+
|
|
605
|
+
input.addEventListener('keydown', function (e) {
|
|
606
|
+
if (e.key === 'Enter') {
|
|
607
|
+
e.preventDefault();
|
|
608
|
+
_apply();
|
|
609
|
+
} else if (e.key === 'Escape') {
|
|
610
|
+
e.preventDefault();
|
|
611
|
+
_cancel();
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// ─── Init ─────────────────────────────────────────────────
|
|
617
|
+
|
|
618
|
+
registerComponent(DOM_SELECTOR, DOM_ATTRIBUTE, _component, 'ln-editor');
|
|
619
|
+
})();
|
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Auto-decorates every cross-host `<a>` and `<area>` on the page with `target="_blank"`, merged `rel="noopener noreferrer"`, and a screen-reader hint span. Runs on page load and on every DOM mutation; no opt-in attribute, no init call, no API surface for consumers to wire.
|
|
4
4
|
|
|
5
|
-
For the host-comparison decision table, script-load lifecycle, and architecture rationale, see [`docs/js/external-links.md`](../../docs/js/external-links.md).
|
|
6
|
-
|
|
7
5
|
## Markup anatomy
|
|
8
6
|
|
|
9
7
|
There is no markup contract. Every `<a>` and `<area>` on the page
|
|
@@ -331,11 +329,52 @@ This self-contained script executes immediately and sets up the necessary hooks
|
|
|
331
329
|
`ln-modal` with this component to show a "leaving the site"
|
|
332
330
|
interstitial. The composition is project code, not library code;
|
|
333
331
|
see the "Confirm-before-leaving interstitial" example.
|
|
334
|
-
- **Architecture deep-dive:** [`docs/js/external-links.md`](../../docs/js/external-links.md)
|
|
335
|
-
for the global-service pattern, the `_isExternalLink` decision
|
|
336
|
-
table, and the script-load lifecycle.
|
|
337
332
|
- **Cross-component principles:** [`docs/architecture/data-flow.md`](../../docs/architecture/data-flow.md)
|
|
338
333
|
— ln-external-links sits OUTSIDE the four-layer data flow. It is
|
|
339
334
|
not Data, Submit, Render, or Validate. It is a global decorator
|
|
340
335
|
that mutates DOM in response to insertion events; the data flow
|
|
341
336
|
story does not apply.
|
|
337
|
+
|
|
338
|
+
---
|
|
339
|
+
|
|
340
|
+
## 🔧 Internals
|
|
341
|
+
|
|
342
|
+
Source: `js/ln-external-links/ln-external-links.js`. A **global service** (js skill §10) — no `registerComponent`, no consumer-facing `data-ln-*` opt-in attribute. It loads, runs once, and sets up a click delegate + `MutationObserver` on `document.body`.
|
|
343
|
+
|
|
344
|
+
### "External" test
|
|
345
|
+
|
|
346
|
+
The single test: truthy `link.hostname` AND `link.hostname !== window.location.hostname`. The `link.hostname &&` short-circuit is what makes every non-http(s) scheme (`mailto:`, `tel:`, `javascript:`, `data:`) resolve as "internal" — those schemes parse to an empty `hostname`, so the check fails before the comparison runs. A subdomain (`app.example.com` on a page served from `example.com`) counts as external — hostname equality is exact, not suffix-based. Port and scheme differences do not affect `hostname` (it strips both), so `https://host:8443` vs `https://host` counts as the same host.
|
|
347
|
+
|
|
348
|
+
### Script-load lifecycle
|
|
349
|
+
|
|
350
|
+
Both the click delegate and the observer are wrapped in `guardBody` (`ln-core/helpers.js`): if `document.body` is `null` at script eval (e.g. loaded in `<head>` without `defer`), setup re-schedules itself for `DOMContentLoaded`. The initial `_processLinks()` scan follows the same `readyState === 'loading'` check. A `window.lnExternalLinks` sentinel guards against double-execution if the script loads twice.
|
|
351
|
+
|
|
352
|
+
### Processing pipeline
|
|
353
|
+
|
|
354
|
+
`_processLink(link)`, on every pass (initial scan, observer callback, manual `.process()` call):
|
|
355
|
+
|
|
356
|
+
1. `data-ln-external-link === 'processed'` → return (idempotency guard, runs first on every pass).
|
|
357
|
+
2. `!_isExternalLink(link)` → return.
|
|
358
|
+
3. `target = '_blank'` (clobbers any pre-existing value).
|
|
359
|
+
4. `rel` tokens merged: `noopener` + `noreferrer` added, existing tokens (`me`, `nofollow`, etc.) survive.
|
|
360
|
+
5. `<span class="sr-only">(opens in new tab)</span>` appended as last child.
|
|
361
|
+
6. `data-ln-external-link="processed"` written (the marker for future passes).
|
|
362
|
+
7. `ln-external-links:processed` dispatched (bubbles, not cancelable).
|
|
363
|
+
|
|
364
|
+
There is no "before-process" cancelable event — decoration is unconditional once the guards pass.
|
|
365
|
+
|
|
366
|
+
### MutationObserver scope
|
|
367
|
+
|
|
368
|
+
Watches `document.body` with `childList: true, subtree: true, attributes: true, attributeFilter: ['href']`. Catches: new `<a>`/`<area>` insertions (direct match and nested via `querySelectorAll`), and `href` mutations on existing anchors (internal→external flips redecorate automatically). Does NOT catch **external→internal href flips on an already-processed link** — the marker guard short-circuits, so the link stays over-decorated (`target="_blank"` on now-internal URL); recovery is consumer-side (clear the marker, strip the written attributes/span manually). Also does not watch `removedNodes` — unnecessary, since all state lives on the element and is removed with it.
|
|
369
|
+
|
|
370
|
+
### Click delegation
|
|
371
|
+
|
|
372
|
+
One delegate on `document.body`, resolving `e.target.closest('a, area')`. No `preventDefault()` — the browser's default action and all modifier-key semantics (Ctrl/Cmd/middle/Shift-click) pass through untouched. Firing of `:clicked` is gated on the `processed` marker, **not** a fresh `_isExternalLink` re-check — a link pre-marked `processed` in markup (the opt-out hatch) fires `:clicked` even if it's actually internal. `detail.text` reads `link.textContent`, which includes the appended sr-only span text.
|
|
373
|
+
|
|
374
|
+
### Accessibility
|
|
375
|
+
|
|
376
|
+
The sr-only hint has no i18n hook — the string is hard-coded English. Projects needing localization must patch the compiled bundle or pre-mark every link to skip the component.
|
|
377
|
+
|
|
378
|
+
### Cross-component coordination
|
|
379
|
+
|
|
380
|
+
Imports only `dispatch` and `guardBody` from `ln-core/helpers.js`. Does not listen for any `ln-*` event and is not signaled by `ln-ajax`/data-loading components when they inject markup — decoration is purely insertion-driven via the shared observer.
|