Skip to content

Commit 5c1b3bc

Browse files
committed
Route JSON editor native tooltips through dialog tooltip manager
1 parent d05bc3a commit 5c1b3bc

3 files changed

Lines changed: 247 additions & 11 deletions

File tree

js/presets/preset_manager/json_editor_dialog.js

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ export class JSONEditorDialog {
2222
async show() {
2323
// Get current JSON
2424
const currentJSON = this.parentDialog.manager.exportToJSON();
25+
let stopEditorTooltips = () => {};
2526
log.debug('Opening JSON editor dialog', {
2627
jsonLength: currentJSON.length
2728
});
@@ -51,6 +52,7 @@ export class JSONEditorDialog {
5152
this.editor.destroy();
5253
this.editor = null;
5354
}
55+
stopEditorTooltips();
5456
tooltipManager.destroy();
5557
wrapper.close();
5658
});
@@ -139,6 +141,7 @@ export class JSONEditorDialog {
139141

140142
// Add drag-and-drop functionality for JSON files
141143
this.setupDragAndDrop(editorContainer, validationMsg);
144+
stopEditorTooltips = this.replaceNativeEditorTooltips(editorContainer, tooltipManager);
142145

143146
content.appendChild(editorContainer);
144147
dialog.appendChild(content);
@@ -151,6 +154,7 @@ export class JSONEditorDialog {
151154
this.editor.destroy();
152155
this.editor = null;
153156
}
157+
stopEditorTooltips();
154158
tooltipManager.destroy();
155159
wrapper.close();
156160
});
@@ -180,6 +184,63 @@ export class JSONEditorDialog {
180184
});
181185
}
182186

187+
/**
188+
* Routes JSONEditor/Ace title tooltips through the dialog tooltip manager.
189+
* Ace recreates controls while editing, so new and updated title attributes
190+
* must be handled continuously to avoid native and managed tooltips overlapping.
191+
*
192+
* @param {HTMLElement} container - JSONEditor root element
193+
* @param {TooltipManager} tooltipManager - Dialog tooltip manager
194+
* @returns {Function} Cleanup callback
195+
*/
196+
replaceNativeEditorTooltips(container, tooltipManager) {
197+
const attachedElements = new WeakSet();
198+
199+
const replaceTitle = (element) => {
200+
const tooltipText = element?.getAttribute?.('title');
201+
if (!tooltipText) return;
202+
203+
element.dataset.tooltipText = tooltipText;
204+
element.removeAttribute('title');
205+
206+
if (!attachedElements.has(element)) {
207+
tooltipManager.attach(element);
208+
attachedElements.add(element);
209+
}
210+
};
211+
212+
const replaceTitlesInTree = (root) => {
213+
replaceTitle(root);
214+
root?.querySelectorAll?.('[title]').forEach(replaceTitle);
215+
};
216+
217+
replaceTitlesInTree(container);
218+
219+
if (typeof MutationObserver === 'undefined') {
220+
return () => {};
221+
}
222+
223+
const observer = new MutationObserver((mutations) => {
224+
mutations.forEach((mutation) => {
225+
if (mutation.type === 'attributes') {
226+
replaceTitle(mutation.target);
227+
return;
228+
}
229+
230+
mutation.addedNodes.forEach(replaceTitlesInTree);
231+
});
232+
});
233+
234+
observer.observe(container, {
235+
subtree: true,
236+
childList: true,
237+
attributes: true,
238+
attributeFilter: ['title']
239+
});
240+
241+
return () => observer.disconnect();
242+
}
243+
183244
/**
184245
* Sets up drag-and-drop functionality for JSON files
185246
* @param {HTMLElement} container - Container element to attach drag-and-drop
@@ -296,16 +357,25 @@ export class JSONEditorDialog {
296357
* @returns {HTMLElement} Info element
297358
*/
298359
createInfoMessage() {
299-
const infoDiv = document.createElement('div');
300-
infoDiv.className = 'resolution-master-json-editor-info';
301-
infoDiv.innerHTML = `
302-
💡 <strong>Direct JSON editing</strong><br>
360+
const infoDetails = document.createElement('details');
361+
infoDetails.className = 'resolution-master-json-editor-info';
362+
363+
const summary = document.createElement('summary');
364+
summary.className = 'resolution-master-json-editor-info-summary';
365+
summary.innerHTML = '💡 <strong>Direct JSON editing</strong>';
366+
367+
const content = document.createElement('div');
368+
content.className = 'resolution-master-json-editor-info-content';
369+
content.innerHTML = `
303370
Edit the JSON below to modify custom presets and hidden built-in presets.<br>
304371
You can also drag & drop a .json file onto the editor to load it.<br>
305372
Changes will replace current configuration when you click "Apply Changes".
306373
`;
307-
308-
return infoDiv;
374+
375+
infoDetails.appendChild(summary);
376+
infoDetails.appendChild(content);
377+
378+
return infoDetails;
309379
}
310380

311381
/**

js/styles/json-editor-dialog.css

Lines changed: 69 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@
77

88
/* Dialog Container - Component-specific sizing */
99
.resolution-master-json-editor-dialog {
10-
width: 90vw;
11-
height: 90vh;
10+
inset: 0;
11+
width: 100vw;
12+
height: 100vh;
13+
height: 100dvh;
14+
box-sizing: border-box;
15+
border: none;
16+
border-radius: 0;
17+
transform: none;
1218
display: flex;
1319
flex-direction: column;
1420
z-index: var(--z-json-editor);
@@ -21,18 +27,67 @@
2127

2228
/* Info Message */
2329
.resolution-master-json-editor-info {
24-
padding: var(--spacing-12) var(--spacing-20);
2530
background: var(--bg-primary-10);
2631
border-bottom: 1px solid var(--bg-primary-30);
2732
color: var(--color-primary);
2833
font-size: var(--font-md);
34+
}
35+
36+
.resolution-master-json-editor-info-summary {
37+
display: flex;
38+
align-items: center;
39+
gap: var(--spacing-8);
40+
padding: var(--spacing-6) var(--spacing-20);
41+
cursor: pointer;
42+
list-style: none;
43+
user-select: none;
44+
}
45+
46+
.resolution-master-json-editor-info-summary::-webkit-details-marker {
47+
display: none;
48+
}
49+
50+
.resolution-master-json-editor-info-summary::before {
51+
content: '▶';
52+
font-size: var(--font-sm);
53+
transition: transform 0.15s ease;
54+
}
55+
56+
.resolution-master-json-editor-info[open] > .resolution-master-json-editor-info-summary::before {
57+
transform: rotate(90deg);
58+
}
59+
60+
.resolution-master-json-editor-info-summary::after {
61+
content: 'Show instructions';
62+
margin-left: auto;
63+
color: var(--color-gray-300);
64+
font-size: var(--font-sm);
65+
}
66+
67+
.resolution-master-json-editor-info[open] > .resolution-master-json-editor-info-summary::after {
68+
content: 'Hide instructions';
69+
}
70+
71+
.resolution-master-json-editor-info-summary:hover {
72+
background: var(--bg-primary-10);
73+
}
74+
75+
.resolution-master-json-editor-info-summary:focus-visible {
76+
outline: 2px solid var(--color-primary);
77+
outline-offset: -2px;
78+
}
79+
80+
.resolution-master-json-editor-info-content {
81+
padding: var(--spacing-4) var(--spacing-20) var(--spacing-12);
82+
border-top: 1px solid var(--bg-primary-30);
2983
line-height: 1.5;
3084
}
3185

3286
/* Content Area */
3387
.resolution-master-json-editor-content {
3488
flex: 1;
35-
padding: var(--spacing-20);
89+
min-height: 0;
90+
padding: 0;
3691
overflow: hidden;
3792
display: flex;
3893
flex-direction: column;
@@ -41,9 +96,10 @@
4196
/* Editor Container */
4297
.resolution-master-json-editor-container {
4398
flex: 1;
99+
min-height: 0;
44100
display: flex;
45101
border: 1px solid var(--color-gray-700);
46-
border-radius: var(--radius-lg);
102+
border-radius: 0;
47103
overflow: hidden;
48104
background: var(--color-bg-dark-3);
49105
transition: all 0.2s ease;
@@ -121,6 +177,14 @@
121177
color: var(--color-gray-100);
122178
}
123179

180+
/* Keep validation text vertically centered with balanced spacing. */
181+
.resolution-master-json-editor-validation {
182+
display: flex;
183+
align-items: center;
184+
box-sizing: border-box;
185+
padding: var(--spacing-5) var(--spacing-20);
186+
}
187+
124188
/* Validation States - border colors for container */
125189
.resolution-master-json-editor-container.valid {
126190
color: var(--color-success);
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import { JSONEditorDialog } from "../../js/presets/preset_manager/json_editor_dialog.js";
5+
6+
class MockElement {
7+
constructor(title = null) {
8+
this.attributes = new Map();
9+
this.children = [];
10+
this.dataset = {};
11+
if (title) this.attributes.set("title", title);
12+
}
13+
14+
getAttribute(name) {
15+
return this.attributes.get(name) ?? null;
16+
}
17+
18+
setAttribute(name, value) {
19+
this.attributes.set(name, value);
20+
}
21+
22+
removeAttribute(name) {
23+
this.attributes.delete(name);
24+
}
25+
26+
appendChild(child) {
27+
this.children.push(child);
28+
}
29+
30+
querySelectorAll(selector) {
31+
assert.equal(selector, "[title]");
32+
return this.children.flatMap((child) => [
33+
...(child.getAttribute("title") ? [child] : []),
34+
...child.querySelectorAll(selector)
35+
]);
36+
}
37+
}
38+
39+
test("JSON editor routes native and dynamically created title tooltips through one manager", () => {
40+
const originalMutationObserver = globalThis.MutationObserver;
41+
let observerInstance;
42+
43+
class MockMutationObserver {
44+
constructor(callback) {
45+
this.callback = callback;
46+
this.disconnected = false;
47+
observerInstance = this;
48+
}
49+
50+
observe(target, options) {
51+
this.target = target;
52+
this.options = options;
53+
}
54+
55+
disconnect() {
56+
this.disconnected = true;
57+
}
58+
}
59+
60+
globalThis.MutationObserver = MockMutationObserver;
61+
62+
try {
63+
const container = new MockElement();
64+
const foldControl = new MockElement("Fold code");
65+
container.appendChild(foldControl);
66+
67+
const attached = [];
68+
const tooltipManager = {
69+
attach(element) {
70+
attached.push(element);
71+
}
72+
};
73+
74+
const dialog = new JSONEditorDialog(null);
75+
const cleanup = dialog.replaceNativeEditorTooltips(container, tooltipManager);
76+
77+
assert.equal(foldControl.getAttribute("title"), null);
78+
assert.equal(foldControl.dataset.tooltipText, "Fold code");
79+
assert.deepEqual(attached, [foldControl]);
80+
assert.deepEqual(observerInstance.options.attributeFilter, ["title"]);
81+
82+
foldControl.setAttribute("title", "Unfold code");
83+
observerInstance.callback([{ type: "attributes", target: foldControl }]);
84+
85+
assert.equal(foldControl.getAttribute("title"), null);
86+
assert.equal(foldControl.dataset.tooltipText, "Unfold code");
87+
assert.deepEqual(attached, [foldControl]);
88+
89+
const dynamicControl = new MockElement("Compact data");
90+
container.appendChild(dynamicControl);
91+
observerInstance.callback([{ type: "childList", addedNodes: [dynamicControl] }]);
92+
93+
assert.equal(dynamicControl.getAttribute("title"), null);
94+
assert.equal(dynamicControl.dataset.tooltipText, "Compact data");
95+
assert.deepEqual(attached, [foldControl, dynamicControl]);
96+
97+
cleanup();
98+
assert.equal(observerInstance.disconnected, true);
99+
} finally {
100+
globalThis.MutationObserver = originalMutationObserver;
101+
}
102+
});

0 commit comments

Comments
 (0)