hyperclayjs 1.14.0 → 1.15.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 +6 -6
- package/package.json +1 -1
- package/src/core/optionVisibility.js +88 -27
- package/src/core/savePage.js +87 -19
- package/src/dom-utilities/insertStyleTag.js +60 -22
- package/src/hyperclay.js +1 -1
package/README.md
CHANGED
|
@@ -60,10 +60,10 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
60
60
|
| autosave | 0.9KB | Auto-save on DOM changes |
|
|
61
61
|
| edit-mode | 1.8KB | Toggle edit mode on hyperclay on/off |
|
|
62
62
|
| edit-mode-helpers | 7.5KB | Admin-only functionality: [edit-mode-input], [edit-mode-resource], [edit-mode-onclick] |
|
|
63
|
-
| option-visibility |
|
|
63
|
+
| option-visibility | 7.8KB | Dynamic show/hide based on ancestor state with option:attribute="value" |
|
|
64
64
|
| persist | 2.4KB | Persist input/select/textarea values to the DOM with [persist] attribute |
|
|
65
65
|
| save-core | 6.8KB | Basic save function only - hyperclay.savePage() |
|
|
66
|
-
| save-system |
|
|
66
|
+
| save-system | 9.6KB | CMD+S, [trigger-save] button, savestatus attribute |
|
|
67
67
|
| save-toast | 0.9KB | Toast notifications for save events |
|
|
68
68
|
| snapshot | 7.5KB | Source of truth for page state - captures DOM snapshots for save and sync |
|
|
69
69
|
| tailwind-inject | 0.4KB | Injects tailwind CSS link with cache-bust on save |
|
|
@@ -106,7 +106,7 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
106
106
|
| all-js | 14.4KB | Full DOM manipulation library |
|
|
107
107
|
| dom-ready | 0.4KB | DOM ready callback |
|
|
108
108
|
| form-data | 2KB | Extract form data as an object |
|
|
109
|
-
| style-injection |
|
|
109
|
+
| style-injection | 4KB | Dynamic stylesheet injection |
|
|
110
110
|
|
|
111
111
|
### String Utilities (String manipulation helpers)
|
|
112
112
|
|
|
@@ -132,17 +132,17 @@ import 'hyperclayjs/presets/standard.js';
|
|
|
132
132
|
|
|
133
133
|
## Presets
|
|
134
134
|
|
|
135
|
-
### Minimal (~
|
|
135
|
+
### Minimal (~40.6KB)
|
|
136
136
|
Essential features for basic editing
|
|
137
137
|
|
|
138
138
|
**Modules:** `save-core`, `snapshot`, `save-system`, `edit-mode-helpers`, `toast`, `save-toast`, `export-to-window`, `view-mode-excludes-edit-modules`
|
|
139
139
|
|
|
140
|
-
### Standard (~
|
|
140
|
+
### Standard (~62.4KB)
|
|
141
141
|
Standard feature set for most use cases
|
|
142
142
|
|
|
143
143
|
**Modules:** `save-core`, `snapshot`, `save-system`, `unsaved-warning`, `edit-mode-helpers`, `persist`, `option-visibility`, `event-attrs`, `dom-helpers`, `toast`, `save-toast`, `export-to-window`, `view-mode-excludes-edit-modules`
|
|
144
144
|
|
|
145
|
-
### Everything (~
|
|
145
|
+
### Everything (~184.6KB)
|
|
146
146
|
All available features
|
|
147
147
|
|
|
148
148
|
Includes all available modules across all categories.
|
package/package.json
CHANGED
|
@@ -1,18 +1,26 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Option Visibility (CSS Layers Implementation)
|
|
3
3
|
*
|
|
4
|
-
* Shows/hides elements based on `option:`
|
|
4
|
+
* Shows/hides elements based on `option:` and `option-not:` attributes.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* SYNTAX:
|
|
7
|
+
* option:name="value" - Show when ancestor has name="value"
|
|
8
|
+
* option:name="a|b|c" - Show when ancestor has name="a" OR "b" OR "c"
|
|
9
|
+
* option:name="" - Show when ancestor has name="" (empty value)
|
|
10
|
+
* option:name="|saved" - Show when ancestor has name="" OR name="saved"
|
|
11
|
+
* option-not:name="value" - Show when ancestor has name attr but ≠ "value"
|
|
12
|
+
* option-not:name="a|b" - Show when ancestor has name attr but ≠ "a" AND ≠ "b"
|
|
13
|
+
*
|
|
14
|
+
* EXAMPLES:
|
|
7
15
|
* <div editmode="true">
|
|
8
|
-
* <button option:editmode="true">Visible</button>
|
|
16
|
+
* <button option:editmode="true">Visible in edit mode</button>
|
|
9
17
|
* <button option:editmode="false">Hidden</button>
|
|
10
18
|
* </div>
|
|
11
19
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
20
|
+
* <div savestatus="error">
|
|
21
|
+
* <span option:savestatus="saved|error">Visible (matches error)</span>
|
|
22
|
+
* <span option-not:savestatus="saving">Visible (error ≠ saving)</span>
|
|
23
|
+
* </div>
|
|
16
24
|
*
|
|
17
25
|
* HOW IT WORKS:
|
|
18
26
|
* 1. Uses `display: none !important` to forcefully hide elements
|
|
@@ -21,13 +29,14 @@
|
|
|
21
29
|
* 3. This preserves the user's original `display` (flex, grid, block) without us knowing what it is
|
|
22
30
|
*
|
|
23
31
|
* BROWSER SUPPORT:
|
|
24
|
-
* Requires `@layer
|
|
32
|
+
* Requires `@layer`, `revert-layer`, and `:not()` selector lists (~92% of browsers, 2022+).
|
|
25
33
|
* Falls back gracefully - elements remain visible if unsupported.
|
|
26
34
|
*
|
|
27
35
|
* TRADEOFFS:
|
|
28
36
|
* - Pro: Pure CSS after generation, zero JS overhead for toggling
|
|
29
37
|
* - Pro: Simple code, similar to original approach
|
|
30
38
|
* - Con: Loses to user `!important` rules (layered !important < unlayered !important)
|
|
39
|
+
* - Con: Pipe character `|` cannot be used as a literal value (reserved as OR delimiter)
|
|
31
40
|
*/
|
|
32
41
|
|
|
33
42
|
import Mutation from "../utilities/mutation.js";
|
|
@@ -35,6 +44,34 @@ import insertStyles from "../dom-utilities/insertStyleTag.js";
|
|
|
35
44
|
|
|
36
45
|
const STYLE_NAME = 'option-visibility';
|
|
37
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Parse an option:/option-not: attribute into a pattern object.
|
|
49
|
+
* Pure function for easy testing.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} attrName - Attribute name (e.g., 'option:editmode', 'option-not:status')
|
|
52
|
+
* @param {string} attrValue - Attribute value (e.g., 'true', 'a|b|c')
|
|
53
|
+
* @returns {Object|null} Pattern object or null if not a valid option attribute
|
|
54
|
+
*/
|
|
55
|
+
export function parseOptionAttribute(attrName, attrValue) {
|
|
56
|
+
let negated = false;
|
|
57
|
+
let name;
|
|
58
|
+
|
|
59
|
+
if (attrName.startsWith('option-not:')) {
|
|
60
|
+
negated = true;
|
|
61
|
+
name = attrName.slice(11);
|
|
62
|
+
} else if (attrName.startsWith('option:')) {
|
|
63
|
+
name = attrName.slice(7);
|
|
64
|
+
} else {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const rawValue = attrValue;
|
|
69
|
+
// Split by pipe, keep empty strings (they match empty attribute values)
|
|
70
|
+
const values = rawValue.split('|');
|
|
71
|
+
|
|
72
|
+
return { name, rawValue, values, negated };
|
|
73
|
+
}
|
|
74
|
+
|
|
38
75
|
const optionVisibility = {
|
|
39
76
|
debug: false,
|
|
40
77
|
_started: false,
|
|
@@ -54,14 +91,15 @@ const optionVisibility = {
|
|
|
54
91
|
},
|
|
55
92
|
|
|
56
93
|
/**
|
|
57
|
-
* Find all unique option:
|
|
94
|
+
* Find all unique option:/option-not: patterns using XPath
|
|
95
|
+
* Returns array of { name, rawValue, values, negated }
|
|
58
96
|
*/
|
|
59
97
|
findOptionAttributes() {
|
|
60
98
|
const patterns = new Map();
|
|
61
99
|
|
|
62
100
|
try {
|
|
63
101
|
const snapshot = document.evaluate(
|
|
64
|
-
'//*[@*[starts-with(name(), "option:")]]',
|
|
102
|
+
'//*[@*[starts-with(name(), "option:") or starts-with(name(), "option-not:")]]',
|
|
65
103
|
document.documentElement,
|
|
66
104
|
null,
|
|
67
105
|
XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE,
|
|
@@ -71,13 +109,12 @@ const optionVisibility = {
|
|
|
71
109
|
for (let i = 0; i < snapshot.snapshotLength; i++) {
|
|
72
110
|
const el = snapshot.snapshotItem(i);
|
|
73
111
|
for (const attr of el.attributes) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
112
|
+
const pattern = parseOptionAttribute(attr.name, attr.value);
|
|
113
|
+
if (!pattern) continue;
|
|
114
|
+
|
|
115
|
+
const key = `${pattern.negated ? '!' : ''}${pattern.name}=${pattern.rawValue}`;
|
|
116
|
+
if (!patterns.has(key)) {
|
|
117
|
+
patterns.set(key, pattern);
|
|
81
118
|
}
|
|
82
119
|
}
|
|
83
120
|
}
|
|
@@ -91,16 +128,34 @@ const optionVisibility = {
|
|
|
91
128
|
/**
|
|
92
129
|
* Generate CSS rules wrapped in @layer
|
|
93
130
|
*/
|
|
94
|
-
generateCSS(
|
|
95
|
-
if (!
|
|
131
|
+
generateCSS(patterns) {
|
|
132
|
+
if (!patterns.length) return '';
|
|
96
133
|
|
|
97
|
-
const rules =
|
|
134
|
+
const rules = patterns.map(({ name, rawValue, values, negated }) => {
|
|
98
135
|
const safeName = CSS.escape(name);
|
|
99
|
-
const
|
|
136
|
+
const safeRawValue = CSS.escape(rawValue);
|
|
137
|
+
const prefix = negated ? 'option-not' : 'option';
|
|
138
|
+
const attrSelector = `[${prefix}\\:${safeName}="${safeRawValue}"]`;
|
|
139
|
+
|
|
140
|
+
// Hide rule (same for both types)
|
|
141
|
+
const hideRule = `${attrSelector}{display:none!important}`;
|
|
142
|
+
|
|
143
|
+
// Show rule depends on type
|
|
144
|
+
let showRule;
|
|
145
|
+
if (negated) {
|
|
146
|
+
// option-not: show when ancestor has attr but NOT any of the values
|
|
147
|
+
// Uses :not(sel1, sel2) selector list syntax
|
|
148
|
+
const notList = values.map(v => `[${safeName}="${CSS.escape(v)}"]`).join(',');
|
|
149
|
+
showRule = `[${safeName}]:not(${notList}) ${attrSelector}{display:revert-layer!important}`;
|
|
150
|
+
} else {
|
|
151
|
+
// option: show when ancestor has ANY of the values
|
|
152
|
+
const showSelectors = values.map(v =>
|
|
153
|
+
`[${safeName}="${CSS.escape(v)}"] ${attrSelector}`
|
|
154
|
+
).join(',');
|
|
155
|
+
showRule = `${showSelectors}{display:revert-layer!important}`;
|
|
156
|
+
}
|
|
100
157
|
|
|
101
|
-
|
|
102
|
-
// Both rules need !important for consistency within the layer
|
|
103
|
-
return `[option\\:${safeName}="${safeValue}"]{display:none!important}[${safeName}="${safeValue}"] [option\\:${safeName}="${safeValue}"]{display:revert-layer!important}`;
|
|
158
|
+
return hideRule + showRule;
|
|
104
159
|
}).join('');
|
|
105
160
|
|
|
106
161
|
return `@layer ${STYLE_NAME}{${rules}}`;
|
|
@@ -118,7 +173,11 @@ const optionVisibility = {
|
|
|
118
173
|
try {
|
|
119
174
|
const attributes = this.findOptionAttributes();
|
|
120
175
|
const css = this.generateCSS(attributes);
|
|
121
|
-
|
|
176
|
+
// mutations-ignore: This style tag is regenerated on load. Without this,
|
|
177
|
+
// the mutation observer would detect it as a change, delaying the settled signal.
|
|
178
|
+
insertStyles(STYLE_NAME, css, (style) => {
|
|
179
|
+
style.setAttribute('mutations-ignore', '');
|
|
180
|
+
});
|
|
122
181
|
this.log(`Generated ${attributes.length} rules`);
|
|
123
182
|
} catch (error) {
|
|
124
183
|
console.error('[OptionVisibility:Layer] Error generating rules:', error);
|
|
@@ -142,12 +201,14 @@ const optionVisibility = {
|
|
|
142
201
|
|
|
143
202
|
this.update();
|
|
144
203
|
|
|
145
|
-
// selectorFilter only triggers on option
|
|
204
|
+
// selectorFilter only triggers on option:/option-not: attribute changes (new patterns).
|
|
146
205
|
// Ancestor attribute changes (e.g., editmode="true" -> "false") are handled
|
|
147
206
|
// automatically by the browser - CSS rules re-evaluate when attributes change.
|
|
148
207
|
this._unsubscribe = Mutation.onAnyChange({
|
|
149
208
|
debounce: 200,
|
|
150
|
-
selectorFilter: el => [...el.attributes].some(attr =>
|
|
209
|
+
selectorFilter: el => [...el.attributes].some(attr =>
|
|
210
|
+
attr.name.startsWith('option:') || attr.name.startsWith('option-not:')
|
|
211
|
+
),
|
|
151
212
|
omitChangeDetails: true
|
|
152
213
|
}, () => this.update());
|
|
153
214
|
|
package/src/core/savePage.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import throttle from "../utilities/throttle.js";
|
|
14
|
+
import Mutation from "../utilities/mutation.js";
|
|
14
15
|
import { isEditMode, isOwner } from "./isAdminOfCurrentResource.js";
|
|
15
16
|
import {
|
|
16
17
|
savePage as savePageCore,
|
|
@@ -19,6 +20,11 @@ import {
|
|
|
19
20
|
beforeSave
|
|
20
21
|
} from "./savePageCore.js";
|
|
21
22
|
|
|
23
|
+
// Reset savestatus to 'saved' in snapshots (each module cleans up its own attrs)
|
|
24
|
+
beforeSave(clone => {
|
|
25
|
+
clone.setAttribute('savestatus', 'saved');
|
|
26
|
+
});
|
|
27
|
+
|
|
22
28
|
// ============================================
|
|
23
29
|
// SAVE STATE MANAGEMENT
|
|
24
30
|
// ============================================
|
|
@@ -94,17 +100,6 @@ export function setUnsavedChanges(val) { unsavedChanges = val; }
|
|
|
94
100
|
export function getLastSavedContents() { return lastSavedContents; }
|
|
95
101
|
export function setLastSavedContents(val) { lastSavedContents = val; }
|
|
96
102
|
|
|
97
|
-
// Initialize lastSavedContents on page load to match what's on disk
|
|
98
|
-
// This prevents unnecessary save attempts when content hasn't changed
|
|
99
|
-
document.addEventListener('DOMContentLoaded', () => {
|
|
100
|
-
if (isEditMode) {
|
|
101
|
-
// Capture initial state immediately for comparison
|
|
102
|
-
lastSavedContents = getPageContents();
|
|
103
|
-
// Set initial save status to 'saved'
|
|
104
|
-
document.documentElement.setAttribute('savestatus', 'saved');
|
|
105
|
-
}
|
|
106
|
-
});
|
|
107
|
-
|
|
108
103
|
/**
|
|
109
104
|
* Save the current page with change detection and state management
|
|
110
105
|
*
|
|
@@ -185,14 +180,87 @@ const throttledSave = throttle(savePage, 1200);
|
|
|
185
180
|
// Baseline for autosave comparison
|
|
186
181
|
let baselineContents = '';
|
|
187
182
|
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
183
|
+
// ============================================
|
|
184
|
+
// BASELINE CAPTURE (Settled Signal)
|
|
185
|
+
// ============================================
|
|
186
|
+
//
|
|
187
|
+
// WHY SETTLED SIGNAL:
|
|
188
|
+
// Modules run on load and mutate the DOM (add styles, modify attributes).
|
|
189
|
+
// A fixed delay (e.g., 1500ms) is arbitrary and either too short (misses slow
|
|
190
|
+
// mutations) or too long (delays baseline). Instead, we wait for mutations to
|
|
191
|
+
// stop, meaning all modules have finished their setup work.
|
|
192
|
+
//
|
|
193
|
+
// WHY IMMEDIATE + CONDITIONAL UPDATE:
|
|
194
|
+
// We set baseline immediately as a safety net. If the user edits or saves
|
|
195
|
+
// before settle completes, we don't overwrite their work. The settled snapshot
|
|
196
|
+
// only replaces baseline if nothing changed (lastSavedContents === immediateContents).
|
|
197
|
+
|
|
198
|
+
const SETTLE_MS = 500; // Wait for no mutations for this long
|
|
199
|
+
const MAX_SETTLE_MS = 3000; // Max time to wait before forcing capture
|
|
200
|
+
|
|
201
|
+
function initBaselineCapture() {
|
|
202
|
+
if (!isEditMode) return;
|
|
203
|
+
|
|
204
|
+
let userEdited = false;
|
|
205
|
+
let settled = false;
|
|
206
|
+
let unsubscribeMutation = null;
|
|
207
|
+
|
|
208
|
+
// Take immediate snapshot and set as baseline right away
|
|
209
|
+
// This ensures saves during settle window work correctly
|
|
210
|
+
const immediateContents = getPageContents();
|
|
211
|
+
lastSavedContents = immediateContents;
|
|
212
|
+
baselineContents = immediateContents;
|
|
213
|
+
|
|
214
|
+
// Track user edits to avoid overwriting real changes
|
|
215
|
+
const userEditEvents = ['input', 'change', 'paste'];
|
|
216
|
+
const markUserEdited = (e) => {
|
|
217
|
+
const target = e.target;
|
|
218
|
+
const isEditable = target.isContentEditable ||
|
|
219
|
+
target.tagName === 'INPUT' ||
|
|
220
|
+
target.tagName === 'TEXTAREA' ||
|
|
221
|
+
target.tagName === 'SELECT';
|
|
222
|
+
if (isEditable) userEdited = true;
|
|
223
|
+
};
|
|
224
|
+
userEditEvents.forEach(evt => document.addEventListener(evt, markUserEdited, true));
|
|
225
|
+
|
|
226
|
+
// Called when mutations settle OR max timeout reached
|
|
227
|
+
const captureBaseline = () => {
|
|
228
|
+
if (settled) return;
|
|
229
|
+
settled = true;
|
|
230
|
+
|
|
231
|
+
// Cleanup listeners
|
|
232
|
+
if (unsubscribeMutation) unsubscribeMutation();
|
|
233
|
+
userEditEvents.forEach(evt => document.removeEventListener(evt, markUserEdited, true));
|
|
234
|
+
|
|
235
|
+
// Only update if no user edits AND no saves occurred during settle
|
|
236
|
+
// (if a save happened, lastSavedContents would differ from immediateContents)
|
|
237
|
+
if (!userEdited && lastSavedContents === immediateContents) {
|
|
238
|
+
const contents = getPageContents();
|
|
239
|
+
lastSavedContents = contents;
|
|
240
|
+
baselineContents = contents;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
document.documentElement.setAttribute('savestatus', 'saved');
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// Start settle observer - fires when no mutations for SETTLE_MS
|
|
247
|
+
unsubscribeMutation = Mutation.onAnyChange(
|
|
248
|
+
{ debounce: SETTLE_MS, omitChangeDetails: true },
|
|
249
|
+
captureBaseline
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// Max timeout fallback
|
|
253
|
+
setTimeout(() => {
|
|
254
|
+
if (!settled) captureBaseline();
|
|
255
|
+
}, MAX_SETTLE_MS);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Run when DOM is ready
|
|
259
|
+
if (document.readyState === 'loading') {
|
|
260
|
+
document.addEventListener('DOMContentLoaded', initBaselineCapture);
|
|
261
|
+
} else {
|
|
262
|
+
initBaselineCapture();
|
|
263
|
+
}
|
|
196
264
|
|
|
197
265
|
/**
|
|
198
266
|
* Save the page with throttling, for use with auto-save
|
|
@@ -2,10 +2,20 @@
|
|
|
2
2
|
* Insert styles into the document (inline CSS or external stylesheet).
|
|
3
3
|
*
|
|
4
4
|
* With a persistent DOM (i.e. hyperclay), we need a way to update styles.
|
|
5
|
-
* This function
|
|
6
|
-
*
|
|
7
|
-
* -
|
|
8
|
-
*
|
|
5
|
+
* This function reuses existing elements when possible:
|
|
6
|
+
* - Inline styles: matches by data-name, reuses if content matches
|
|
7
|
+
* - External stylesheets: matches by normalized full URL path
|
|
8
|
+
*
|
|
9
|
+
* This ensures:
|
|
10
|
+
* - No DOM churn: existing elements are reused when content/path matches
|
|
11
|
+
* - No duplicates: removes any duplicate style/link elements
|
|
12
|
+
* - Callback always runs: attributes can be updated on existing elements
|
|
13
|
+
*
|
|
14
|
+
* WHY REUSE IN-PLACE:
|
|
15
|
+
* In a persistent DOM (hyperclay), removing and re-adding elements changes their
|
|
16
|
+
* position and surrounding whitespace. This causes snapshot diffs even when content
|
|
17
|
+
* is identical, triggering false "unsaved changes" warnings. Reusing existing
|
|
18
|
+
* elements preserves DOM structure for stable snapshots.
|
|
9
19
|
*
|
|
10
20
|
* Usage:
|
|
11
21
|
* insertStyles('/path/to/file.css') // External stylesheet
|
|
@@ -18,15 +28,33 @@ function insertStyles(nameOrHref, cssOrCallback, callback) {
|
|
|
18
28
|
// Inline style: insertStyles('my-styles', '.foo { ... }', optionalCallback)
|
|
19
29
|
const name = nameOrHref;
|
|
20
30
|
const css = cssOrCallback;
|
|
21
|
-
const
|
|
31
|
+
const existingStyles = [...document.querySelectorAll(`style[data-name="${name}"]`)];
|
|
32
|
+
|
|
33
|
+
// If exact match exists, just update attributes via callback and return it
|
|
34
|
+
const exactMatch = existingStyles.find(el => el.textContent === css);
|
|
35
|
+
if (exactMatch) {
|
|
36
|
+
if (callback) callback(exactMatch);
|
|
37
|
+
// Remove any duplicates
|
|
38
|
+
existingStyles.filter(el => el !== exactMatch).forEach(el => el.remove());
|
|
39
|
+
return exactMatch;
|
|
40
|
+
}
|
|
22
41
|
|
|
23
|
-
|
|
24
|
-
style
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
42
|
+
// Update first existing style in-place, or create new one
|
|
43
|
+
let style;
|
|
44
|
+
if (existingStyles.length > 0) {
|
|
45
|
+
style = existingStyles[0];
|
|
46
|
+
style.textContent = css;
|
|
47
|
+
if (callback) callback(style);
|
|
48
|
+
// Remove duplicates
|
|
49
|
+
existingStyles.slice(1).forEach(el => el.remove());
|
|
50
|
+
} else {
|
|
51
|
+
style = document.createElement('style');
|
|
52
|
+
style.dataset.name = name;
|
|
53
|
+
style.textContent = css;
|
|
54
|
+
if (callback) callback(style);
|
|
55
|
+
document.head.appendChild(style);
|
|
56
|
+
}
|
|
28
57
|
|
|
29
|
-
oldStyles.forEach(el => el.remove());
|
|
30
58
|
return style;
|
|
31
59
|
}
|
|
32
60
|
|
|
@@ -34,25 +62,35 @@ function insertStyles(nameOrHref, cssOrCallback, callback) {
|
|
|
34
62
|
const href = nameOrHref;
|
|
35
63
|
const cb = typeof cssOrCallback === 'function' ? cssOrCallback : undefined;
|
|
36
64
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
65
|
+
// Normalize href to full URL for comparison
|
|
66
|
+
const normalizedHref = new URL(href, window.location.href).href;
|
|
67
|
+
|
|
68
|
+
// Find all links with matching normalized path
|
|
69
|
+
const existingLinks = [...document.querySelectorAll('link[rel="stylesheet"]')]
|
|
70
|
+
.filter(el => {
|
|
71
|
+
try {
|
|
72
|
+
return new URL(el.getAttribute('href'), window.location.href).href === normalizedHref;
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
44
77
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
78
|
+
// If match exists, just update attributes via callback and return it
|
|
79
|
+
if (existingLinks.length > 0) {
|
|
80
|
+
const link = existingLinks[0];
|
|
81
|
+
if (cb) cb(link);
|
|
82
|
+
// Remove any duplicates
|
|
83
|
+
existingLinks.slice(1).forEach(el => el.remove());
|
|
84
|
+
return link;
|
|
85
|
+
}
|
|
48
86
|
|
|
87
|
+
// Create new link element
|
|
49
88
|
const link = document.createElement('link');
|
|
50
89
|
link.rel = 'stylesheet';
|
|
51
90
|
link.href = href;
|
|
52
91
|
if (cb) cb(link);
|
|
53
92
|
document.head.appendChild(link);
|
|
54
93
|
|
|
55
|
-
oldLinks.forEach(el => el.remove());
|
|
56
94
|
return link;
|
|
57
95
|
}
|
|
58
96
|
|
package/src/hyperclay.js
CHANGED