-
Notifications
You must be signed in to change notification settings - Fork 3
fix(dsh): 修复侧栏背景与设置图标交替闪烁 #38
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+282
−5
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,274 @@ | ||
| import assert from "node:assert/strict"; | ||
| import fs from "node:fs/promises"; | ||
| import test from "node:test"; | ||
| import vm from "node:vm"; | ||
|
|
||
| /** | ||
| * Minimal DOM for console.js placement. Mirrors DSH 0.1.2-alpha.5: | ||
| * footArea > settingsArea > triggerRow (horizontal flex) > settings button. | ||
| * Putting #beauticode-console inside triggerRow squeezes the settings button | ||
| * to zero width, which is Issue #37's flicker loop. | ||
| */ | ||
| class FakeNode { | ||
| constructor(tagName, document) { | ||
| this.tagName = String(tagName).toUpperCase(); | ||
| this.document = document; | ||
| this.id = ""; | ||
| this.className = ""; | ||
| this.parentElement = null; | ||
| this.children = []; | ||
| this.attributes = new Map(); | ||
| this.dataset = {}; | ||
| this.style = {}; | ||
| this.hidden = false; | ||
| this._innerHTML = ""; | ||
| this.textContent = ""; | ||
| this.listeners = new Map(); | ||
| this.classList = { | ||
| toggle: (name, force) => { | ||
| const names = new Set(this.className.split(/\s+/).filter(Boolean)); | ||
| const on = force === undefined ? !names.has(name) : Boolean(force); | ||
| if (on) names.add(name); | ||
| else names.delete(name); | ||
| this.className = [...names].join(" "); | ||
| return on; | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| get nextElementSibling() { | ||
| if (!this.parentElement) return null; | ||
| const siblings = this.parentElement.children; | ||
| return siblings[siblings.indexOf(this) + 1] ?? null; | ||
| } | ||
|
|
||
| setAttribute(name, value) { | ||
| this.attributes.set(name, String(value)); | ||
| if (name === "id") this.id = String(value); | ||
| if (name === "class") this.className = String(value); | ||
| if (name.startsWith("data-")) { | ||
| const key = name | ||
| .slice(5) | ||
| .replace(/-([a-z])/g, (_all, ch) => ch.toUpperCase()); | ||
| this.dataset[key] = String(value); | ||
| } | ||
| } | ||
|
|
||
| getAttribute(name) { | ||
| if (name === "id") return this.id || null; | ||
| if (name === "class") return this.className || null; | ||
| return this.attributes.get(name) ?? null; | ||
| } | ||
|
|
||
| hasAttribute(name) { | ||
| return this.getAttribute(name) != null; | ||
| } | ||
|
|
||
| set innerHTML(html) { | ||
| this._innerHTML = String(html); | ||
| for (const child of [...this.children]) child.remove(); | ||
| const re = /<([a-z0-9]+)([^>]*)>/gi; | ||
| let match; | ||
| while ((match = re.exec(this._innerHTML))) { | ||
| const tag = match[1].toLowerCase(); | ||
| if (["svg", "path", "rect", "circle", "strong", "small", "h2"].includes(tag)) { | ||
| continue; | ||
| } | ||
| const attrs = match[2]; | ||
| const classMatch = attrs.match(/class="([^"]*)"/); | ||
| if (tag === "span" && !classMatch) continue; | ||
| const child = this.document.createElement(tag); | ||
| if (classMatch) child.className = classMatch[1]; | ||
| for (const attr of attrs.matchAll(/([a-z0-9:-]+)="([^"]*)"/gi)) { | ||
| if (attr[1] === "class") continue; | ||
| child.setAttribute(attr[1], attr[2]); | ||
| } | ||
| this.append(child); | ||
| } | ||
| } | ||
|
|
||
| get innerHTML() { | ||
| return this._innerHTML; | ||
| } | ||
|
|
||
| append(...nodes) { | ||
| for (const node of nodes) { | ||
| node.remove(); | ||
| node.parentElement = this; | ||
| this.children.push(node); | ||
| } | ||
| } | ||
|
|
||
| insertBefore(node, ref) { | ||
| node.remove(); | ||
| node.parentElement = this; | ||
| const index = ref ? this.children.indexOf(ref) : -1; | ||
| if (index >= 0) this.children.splice(index, 0, node); | ||
| else this.children.push(node); | ||
| return node; | ||
| } | ||
|
|
||
| remove() { | ||
| if (!this.parentElement) return; | ||
| const siblings = this.parentElement.children; | ||
| const index = siblings.indexOf(this); | ||
| if (index >= 0) siblings.splice(index, 1); | ||
| this.parentElement = null; | ||
| } | ||
|
|
||
| addEventListener(name, handler) { | ||
| const list = this.listeners.get(name) ?? []; | ||
| list.push(handler); | ||
| this.listeners.set(name, list); | ||
| } | ||
|
|
||
| contains(node) { | ||
| for (let current = node; current; current = current.parentElement) { | ||
| if (current === this) return true; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| matches(selector) { | ||
| const attr = selector.match(/^(\w+)?\[([^=\]]+)=["']([^"']+)["']\]$/); | ||
| if (attr) { | ||
| const [, tag, name, value] = attr; | ||
| if (tag && this.tagName !== tag.toUpperCase()) return false; | ||
| return this.getAttribute(name) === value; | ||
| } | ||
| if (selector.startsWith(".")) { | ||
| return this.className.split(/\s+/).includes(selector.slice(1)); | ||
| } | ||
| if (selector.startsWith("#")) return this.id === selector.slice(1); | ||
| return this.tagName === selector.toUpperCase(); | ||
| } | ||
|
|
||
| querySelectorAll(selector) { | ||
| const matches = []; | ||
| for (const child of this.children) { | ||
| if (child.matches(selector)) matches.push(child); | ||
| matches.push(...child.querySelectorAll(selector)); | ||
| } | ||
| return matches; | ||
| } | ||
|
|
||
| querySelector(selector) { | ||
| return this.querySelectorAll(selector)[0] ?? null; | ||
| } | ||
|
|
||
| getBoundingClientRect() { | ||
| if (this.getAttribute("aria-haspopup") !== "dialog") { | ||
| return { width: 36, height: 36, left: 8, top: 724, bottom: 760 }; | ||
| } | ||
| const squeezed = this.parentElement?.children.some( | ||
| (child) => child.id === "beauticode-console", | ||
| ); | ||
| if (squeezed) { | ||
| return { width: 0, height: 0, left: 8, top: 0, bottom: 0 }; | ||
| } | ||
| return { width: 36, height: 36, left: 8, top: 724, bottom: 760 }; | ||
| } | ||
| } | ||
|
|
||
| function createConsoleDocument() { | ||
| const document = { | ||
| createElement(tagName) { | ||
| const node = new FakeNode(tagName, document); | ||
| if (tagName === "input") node.type = ""; | ||
| return node; | ||
| }, | ||
| addEventListener() {}, | ||
| }; | ||
| const documentElement = new FakeNode("html", document); | ||
| const head = new FakeNode("head", document); | ||
| const body = new FakeNode("body", document); | ||
| document.documentElement = documentElement; | ||
| document.head = head; | ||
| document.body = body; | ||
| documentElement.append(head, body); | ||
| document.querySelectorAll = (selector) => documentElement.querySelectorAll(selector); | ||
| document.getElementById = (id) => documentElement.querySelector(`#${id}`); | ||
| return document; | ||
| } | ||
|
|
||
| function mountAlpha5Sidebar(document) { | ||
| const footArea = document.createElement("div"); | ||
| footArea.id = "foot-area"; | ||
| const settingsArea = document.createElement("div"); | ||
| settingsArea.id = "settings-area"; | ||
| const triggerRow = document.createElement("div"); | ||
| triggerRow.id = "trigger-row"; | ||
| triggerRow.style.display = "flex"; | ||
| triggerRow.style.flexDirection = "row"; | ||
| const settings = document.createElement("button"); | ||
| settings.id = "dsh-settings"; | ||
| settings.setAttribute("aria-haspopup", "dialog"); | ||
| triggerRow.append(settings); | ||
| settingsArea.append(triggerRow); | ||
| footArea.append(settingsArea); | ||
| document.body.append(footArea); | ||
| return { footArea, settingsArea, triggerRow, settings }; | ||
| } | ||
|
|
||
| async function loadConsole(document) { | ||
| const source = await fs.readFile(new URL("../console.js", import.meta.url), "utf8"); | ||
| const ticks = []; | ||
| const context = { | ||
| window: null, | ||
| document, | ||
| MutationObserver: class { | ||
| observe() {} | ||
| }, | ||
| addEventListener() {}, | ||
| innerHeight: 800, | ||
| setInterval: (fn) => { | ||
| ticks.push(fn); | ||
| return ticks.length; | ||
| }, | ||
| fetch: async () => ({ ok: false, json: async () => ({}) }), | ||
| }; | ||
| context.window = context; | ||
| context.globalThis = context; | ||
| vm.runInNewContext(source, context); | ||
| return { | ||
| tick() { | ||
| for (const fn of ticks) fn(); | ||
| }, | ||
| }; | ||
| } | ||
|
|
||
| test("console mounts above the settings area instead of inside the trigger row", async () => { | ||
| const document = createConsoleDocument(); | ||
| const { footArea, settingsArea, triggerRow, settings } = mountAlpha5Sidebar(document); | ||
| const runtime = await loadConsole(document); | ||
|
|
||
| const snapshots = []; | ||
| const capture = () => { | ||
| const host = document.getElementById("beauticode-console"); | ||
| snapshots.push({ | ||
| parent: host?.parentElement?.id ?? null, | ||
| next: host?.nextElementSibling?.id ?? null, | ||
| settingsWidth: settings.getBoundingClientRect().width, | ||
| inTriggerRow: triggerRow.children.includes(host), | ||
| }); | ||
| }; | ||
|
|
||
| capture(); | ||
| for (let i = 0; i < 6; i += 1) { | ||
| runtime.tick(); | ||
| capture(); | ||
| } | ||
|
|
||
| const host = document.getElementById("beauticode-console"); | ||
| assert.equal(host?.parentElement?.id, "foot-area"); | ||
| assert.equal(host?.nextElementSibling?.id, "settings-area"); | ||
| assert.equal(triggerRow.children.map((child) => child.id).join(","), "dsh-settings"); | ||
| assert.equal(host.parentElement, footArea); | ||
| assert.equal(host.nextElementSibling, settingsArea); | ||
| for (const snapshot of snapshots) { | ||
| assert.equal(snapshot.parent, "foot-area"); | ||
| assert.equal(snapshot.next, "settings-area"); | ||
| assert.equal(snapshot.settingsWidth, 36); | ||
| assert.equal(snapshot.inTriggerRow, false); | ||
| } | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The new mount logic unconditionally assumes alpha.5's
button -> triggerRow -> settingsArea -> footAreanesting, although the documented compatibility range still starts at DSH 0.1.0-rc.6 (docs/refactoring.md:69). On earlier supported layouts where the settings button has fewer wrappers, these parent hops identify a sidebar or application container asfootArea, soinsertBeforemounts the background control outside the footer and it can disappear or be laid out incorrectly. Detect the relevant ancestors structurally or retain the previous direct-parent fallback when the alpha.5 shape is absent.Useful? React with 👍 / 👎.