quadview 0.1.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/LICENSE +21 -0
- package/README.md +124 -0
- package/bin/quadview.js +2 -0
- package/package.json +30 -0
- package/src/cli.js +72 -0
- package/src/devices.js +51 -0
- package/src/server.js +153 -0
- package/static/index.html +878 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 QuadView contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# QuadView
|
|
2
|
+
|
|
3
|
+
View your locally running website across four configurable device viewports in a single browser window. No framework-specific setup—works with any dev server (Vite, Next.js, Create React App, Remix, plain HTML).
|
|
4
|
+
|
|
5
|
+
## Requirements
|
|
6
|
+
|
|
7
|
+
- **Node.js** ≥ 18
|
|
8
|
+
- A dev server already running (e.g. `npm run dev`)
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install -g quadview
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Or run without installing:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx quadview http://localhost:5173
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
quadview <url>
|
|
26
|
+
quadview --url <url>
|
|
27
|
+
quadview --port <port> <url>
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Examples:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Start your dev server in one terminal, then:
|
|
34
|
+
quadview http://localhost:5173
|
|
35
|
+
quadview localhost:5173
|
|
36
|
+
quadview --port 3000 http://localhost:5173
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The wrapper opens at **http://127.0.0.1:4242** by default (or the port you set with `--port`). Open that URL in your browser to see your app in a 2×2 grid. Each pane has a device dropdown; change it to resize the iframe. Selections are saved in `localStorage` and restored on reload. Use **Reset** to clear saved device choices.
|
|
40
|
+
|
|
41
|
+
**Proxy mode:** QuadView proxies your dev server so all four viewports load the app from the same origin. This allows **scroll sync** (scroll in one viewport and all four stay in sync) and **navigation sync** (click a link in one viewport and all four navigate). No changes to your app are required. The proxy rewrites `X-Frame-Options` and CSP `frame-ancestors` so framing works even when your dev server blocks it.
|
|
42
|
+
|
|
43
|
+
## Options
|
|
44
|
+
|
|
45
|
+
| Option | Description |
|
|
46
|
+
|--------|-------------|
|
|
47
|
+
| `[url]` | Target URL (e.g. `http://localhost:5173` or `localhost:5173`) |
|
|
48
|
+
| `-u, --url <url>` | Target URL (alternative to positional) |
|
|
49
|
+
| `-p, --port <port>` | Wrapper server port (default: 4242) |
|
|
50
|
+
|
|
51
|
+
## Security
|
|
52
|
+
|
|
53
|
+
- Only **localhost** or **127.0.0.1** is allowed as the target URL.
|
|
54
|
+
- The wrapper server binds to **127.0.0.1** only (not exposed to the network).
|
|
55
|
+
|
|
56
|
+
## Iframe blocking
|
|
57
|
+
|
|
58
|
+
QuadView proxies your app and rewrites `X-Frame-Options` and CSP `frame-ancestors` so the app can be embedded. If you still see a blank frame, check that your dev server is running and that the URL you passed to QuadView is correct.
|
|
59
|
+
|
|
60
|
+
## Publishing (for maintainers)
|
|
61
|
+
|
|
62
|
+
### 1. Clean repo before publishing
|
|
63
|
+
|
|
64
|
+
- Required: `README.md`, `LICENSE` (MIT), `package.json`, `bin/quadview.js`
|
|
65
|
+
- `package.json`: `version` set to `0.1.0`, `bin` → `"quadview": "bin/quadview.js"`, `files` → `["bin", "src", "static"]`
|
|
66
|
+
- No local junk: only `bin`, `src`, and `static` are published (see `files`)
|
|
67
|
+
|
|
68
|
+
### 2. Test locally
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
npm pack
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Then install the tarball in another project to verify:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
cd /tmp
|
|
78
|
+
mkdir quadview-test && cd quadview-test
|
|
79
|
+
npm init -y
|
|
80
|
+
npm install /path/to/package1viewports/quadview-0.1.0.tgz
|
|
81
|
+
npx quadview http://localhost:5173
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
If the QuadView UI opens and proxies your dev server, you’re ready.
|
|
85
|
+
|
|
86
|
+
### 3. Publish to npm
|
|
87
|
+
|
|
88
|
+
1. **Create an npm account** (if needed): [https://www.npmjs.com/signup](https://www.npmjs.com/signup)
|
|
89
|
+
2. **Log in from the terminal:** `npm login`
|
|
90
|
+
3. **Publish (public package):** `npm publish --access public`
|
|
91
|
+
|
|
92
|
+
After that, anyone can run:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
npx quadview http://localhost:5173
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### 4. Tag the release on GitHub
|
|
99
|
+
|
|
100
|
+
1. Push the repo to GitHub.
|
|
101
|
+
2. Go to **Releases** → **Create a new release**.
|
|
102
|
+
3. Tag: `v0.1.0`
|
|
103
|
+
4. Title: e.g. **Initial release**
|
|
104
|
+
5. Include a short usage example in the release notes (helps credibility).
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Letting people know
|
|
109
|
+
|
|
110
|
+
Open source tools only get used if people see them. After publishing:
|
|
111
|
+
|
|
112
|
+
- **Twitter/X** – Short tweet with what it does, link to npm and repo.
|
|
113
|
+
- **Reddit** – r/webdev, r/javascript, r/reactjs (follow each sub’s rules; no spam).
|
|
114
|
+
- **Dev.to / Hashnode** – Short “I built a thing” post with problem, solution, and `npx` example.
|
|
115
|
+
- **Hacker News** – “Show HN: QuadView – four device viewports for local dev” with link and one-liner.
|
|
116
|
+
- **Product Hunt** – List as a dev tool when you’re ready for a launch day.
|
|
117
|
+
- **GitHub** – Clear README, good `keywords` in `package.json`, and a topic like `responsive-design` or `dev-tools` on the repo.
|
|
118
|
+
- **Word of mouth** – Use it yourself, add it to team docs, and mention it in relevant threads or chats.
|
|
119
|
+
|
|
120
|
+
Keep the README and release notes focused on one clear benefit: “See your app in four viewports at once, with sync’d scroll and nav.”
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
package/bin/quadview.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "quadview",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "View your local dev site across four configurable device viewports",
|
|
5
|
+
"main": "src/server.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"quadview": "bin/quadview.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"static"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"responsive",
|
|
19
|
+
"viewport",
|
|
20
|
+
"dev-tools",
|
|
21
|
+
"cli"
|
|
22
|
+
],
|
|
23
|
+
"license": "MIT",
|
|
24
|
+
"scripts": {
|
|
25
|
+
"demo": "node demo/server.js"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"commander": "^12.0.0"
|
|
29
|
+
}
|
|
30
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { program } = require('commander');
|
|
4
|
+
const { start } = require('./server.js');
|
|
5
|
+
|
|
6
|
+
const DEFAULT_PORT = 4242;
|
|
7
|
+
const ALLOWED_HOSTS = ['localhost', '127.0.0.1'];
|
|
8
|
+
|
|
9
|
+
function normalizeUrl(input) {
|
|
10
|
+
const trimmed = String(input).trim();
|
|
11
|
+
if (!trimmed) return null;
|
|
12
|
+
if (/^https?:\/\//i.test(trimmed)) return trimmed;
|
|
13
|
+
if (trimmed.includes(':')) {
|
|
14
|
+
const [host, port] = trimmed.split(':');
|
|
15
|
+
return `http://${host}:${port}`;
|
|
16
|
+
}
|
|
17
|
+
return `http://${trimmed}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function validateLocalhostOnly(urlString) {
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = new URL(urlString);
|
|
24
|
+
} catch {
|
|
25
|
+
return { valid: false, error: 'Invalid URL' };
|
|
26
|
+
}
|
|
27
|
+
const host = parsed.hostname.toLowerCase();
|
|
28
|
+
if (!ALLOWED_HOSTS.includes(host)) {
|
|
29
|
+
return {
|
|
30
|
+
valid: false,
|
|
31
|
+
error: `Only localhost or 127.0.0.1 is allowed. Got: ${parsed.hostname}`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return { valid: true };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
program
|
|
38
|
+
.name('quadview')
|
|
39
|
+
.description('View a local dev site across four device viewports')
|
|
40
|
+
.argument('[url]', 'Target URL (e.g. http://localhost:5173 or localhost:5173)')
|
|
41
|
+
.option('-u, --url <url>', 'Target URL (alternative to positional)')
|
|
42
|
+
.option('-p, --port <port>', 'Wrapper server port', String(DEFAULT_PORT))
|
|
43
|
+
.action((positionalUrl, options) => {
|
|
44
|
+
const urlOpt = options.url;
|
|
45
|
+
const url = urlOpt != null ? urlOpt : positionalUrl;
|
|
46
|
+
if (url == null || (typeof url === 'string' && url.trim() === '')) {
|
|
47
|
+
console.error('Error: URL is required. Use quadview <url> or quadview --url <url>');
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
const rawUrl = normalizeUrl(url);
|
|
51
|
+
if (!rawUrl) {
|
|
52
|
+
console.error('Error: Invalid URL.');
|
|
53
|
+
process.exit(1);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const validation = validateLocalhostOnly(rawUrl);
|
|
57
|
+
if (!validation.valid) {
|
|
58
|
+
console.error('Error:', validation.error);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const port = parseInt(options.port, 10);
|
|
63
|
+
if (Number.isNaN(port) || port < 1 || port > 65535) {
|
|
64
|
+
console.error('Error: --port must be a number between 1 and 65535');
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
start(port, rawUrl);
|
|
69
|
+
console.log(`QuadView running at http://127.0.0.1:${port}`);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
program.parse();
|
package/src/devices.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default device presets: name, width, height, group, shellType.
|
|
3
|
+
* shellType drives the hardware frame: phone | tablet | laptop | desktop.
|
|
4
|
+
*/
|
|
5
|
+
const DEVICES = [
|
|
6
|
+
// Phones
|
|
7
|
+
{ id: 'iphone-se', name: 'iPhone SE', width: 375, height: 667, group: 'Phones', shellType: 'phone' },
|
|
8
|
+
{ id: 'iphone-12-13-mini', name: 'iPhone 12/13 mini', width: 375, height: 812, group: 'Phones', shellType: 'phone' },
|
|
9
|
+
{ id: 'iphone-14', name: 'iPhone 14', width: 390, height: 844, group: 'Phones', shellType: 'phone' },
|
|
10
|
+
{ id: 'iphone-14-plus', name: 'iPhone 14 Plus', width: 428, height: 926, group: 'Phones', shellType: 'phone' },
|
|
11
|
+
{ id: 'iphone-14-pro-max', name: 'iPhone 14 Pro Max', width: 430, height: 932, group: 'Phones', shellType: 'phone' },
|
|
12
|
+
{ id: 'pixel-7', name: 'Pixel 7', width: 412, height: 915, group: 'Phones', shellType: 'phone' },
|
|
13
|
+
{ id: 'pixel-7-pro', name: 'Pixel 7 Pro', width: 412, height: 915, group: 'Phones', shellType: 'phone' },
|
|
14
|
+
{ id: 'samsung-s21', name: 'Samsung Galaxy S21', width: 360, height: 800, group: 'Phones', shellType: 'phone' },
|
|
15
|
+
{ id: 'samsung-s21-ultra', name: 'Samsung S21 Ultra', width: 384, height: 854, group: 'Phones', shellType: 'phone' },
|
|
16
|
+
{ id: 'galaxy-fold', name: 'Galaxy Z Fold', width: 512, height: 722, group: 'Phones', shellType: 'phone' },
|
|
17
|
+
// Tablets
|
|
18
|
+
{ id: 'ipad-mini', name: 'iPad mini', width: 768, height: 1024, group: 'Tablets', shellType: 'tablet' },
|
|
19
|
+
{ id: 'ipad', name: 'iPad', width: 810, height: 1080, group: 'Tablets', shellType: 'tablet' },
|
|
20
|
+
{ id: 'ipad-air', name: 'iPad Air', width: 820, height: 1180, group: 'Tablets', shellType: 'tablet' },
|
|
21
|
+
{ id: 'ipad-pro-11', name: 'iPad Pro 11"', width: 834, height: 1194, group: 'Tablets', shellType: 'tablet' },
|
|
22
|
+
{ id: 'ipad-pro-12', name: 'iPad Pro 12.9"', width: 1024, height: 1366, group: 'Tablets', shellType: 'tablet' },
|
|
23
|
+
{ id: 'pixel-tablet', name: 'Pixel Tablet', width: 1024, height: 600, group: 'Tablets', shellType: 'tablet' },
|
|
24
|
+
{ id: 'surface-pro', name: 'Surface Pro 9', width: 912, height: 1368, group: 'Tablets', shellType: 'tablet' },
|
|
25
|
+
// Laptops
|
|
26
|
+
{ id: 'macbook-13', name: 'MacBook 13"', width: 1280, height: 800, group: 'Laptops', shellType: 'laptop' },
|
|
27
|
+
{ id: 'macbook-14', name: 'MacBook 14"', width: 1512, height: 982, group: 'Laptops', shellType: 'laptop' },
|
|
28
|
+
{ id: 'macbook-16', name: 'MacBook 16"', width: 1728, height: 1117, group: 'Laptops', shellType: 'laptop' },
|
|
29
|
+
{ id: 'laptop-1366', name: 'Laptop 1366×768', width: 1366, height: 768, group: 'Laptops', shellType: 'laptop' },
|
|
30
|
+
{ id: 'laptop-1440', name: 'Laptop 1440×900', width: 1440, height: 900, group: 'Laptops', shellType: 'laptop' },
|
|
31
|
+
{ id: 'chromebook', name: 'Chromebook', width: 1280, height: 800, group: 'Laptops', shellType: 'laptop' },
|
|
32
|
+
// Desktops
|
|
33
|
+
{ id: 'desktop-1080', name: 'Desktop 1080p', width: 1920, height: 1080, group: 'Desktops', shellType: 'desktop' },
|
|
34
|
+
{ id: 'desktop-1440', name: 'Desktop 1440p', width: 2560, height: 1440, group: 'Desktops', shellType: 'desktop' },
|
|
35
|
+
{ id: 'desktop-4k', name: 'Desktop 4K', width: 3840, height: 2160, group: 'Desktops', shellType: 'desktop' },
|
|
36
|
+
{ id: 'imac-24', name: 'iMac 24"', width: 4480, height: 2520, group: 'Desktops', shellType: 'desktop' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function getDevicesByGroup() {
|
|
40
|
+
const groups = {};
|
|
41
|
+
for (const d of DEVICES) {
|
|
42
|
+
if (!groups[d.group]) groups[d.group] = [];
|
|
43
|
+
groups[d.group].push(d);
|
|
44
|
+
}
|
|
45
|
+
return groups;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
module.exports = {
|
|
49
|
+
DEVICES,
|
|
50
|
+
getDevicesByGroup,
|
|
51
|
+
};
|
package/src/server.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const https = require('https');
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const zlib = require('zlib');
|
|
6
|
+
const { DEVICES } = require('./devices.js');
|
|
7
|
+
|
|
8
|
+
const TEMPLATE_PATH = path.join(__dirname, '..', 'static', 'index.html');
|
|
9
|
+
const PROXY_PREFIX = '/proxy';
|
|
10
|
+
|
|
11
|
+
const QUADVIEW_SYNC_SCRIPT = `<script>(function(){var raf=0,ignoreUntil=0,lastSent=0,minInterval=16;function send(){if(ignoreUntil&&Date.now()<ignoreUntil)return;if(window.parent===window)return;var now=Date.now();if(now-lastSent<minInterval)return;lastSent=now;try{window.parent.postMessage({type:'quadview-scroll',x:window.scrollX,y:window.scrollY},'*');}catch(e){}}function onScroll(){if(raf)return;raf=1;requestAnimationFrame(function(){raf=0;send();});}window.addEventListener('scroll',onScroll,{passive:true});window.addEventListener('load',send);window.addEventListener('message',function(e){if(!e.data||e.data.type!=='quadview-sync-scroll')return;ignoreUntil=Date.now()+80;var x=e.data.x,y=e.data.y;requestAnimationFrame(function(){window.scrollTo(x,y);});});})();</script>`;
|
|
12
|
+
|
|
13
|
+
function escapeForScript(str) {
|
|
14
|
+
return String(str)
|
|
15
|
+
.replace(/\\/g, '\\\\')
|
|
16
|
+
.replace(/"/g, '\\"')
|
|
17
|
+
.replace(/\n/g, '\\n')
|
|
18
|
+
.replace(/\r/g, '\\r')
|
|
19
|
+
.replace(/\u2028/g, '\\u2028')
|
|
20
|
+
.replace(/\u2029/g, '\\u2029');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getWrapperHtml(proxyBaseUrl) {
|
|
24
|
+
const template = fs.readFileSync(TEMPLATE_PATH, 'utf8');
|
|
25
|
+
const escapedUrl = escapeForScript(proxyBaseUrl);
|
|
26
|
+
const devicesJson = JSON.stringify(DEVICES);
|
|
27
|
+
return template
|
|
28
|
+
.replace(/\{\{TARGET_URL_ESCAPED\}\}/g, escapedUrl)
|
|
29
|
+
.replace(/\{\{DEVICES_JSON\}\}/g, devicesJson);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function injectSyncScript(html) {
|
|
33
|
+
if (!html || typeof html !== 'string') return html;
|
|
34
|
+
const closeBody = html.lastIndexOf('</body>');
|
|
35
|
+
if (closeBody === -1) return html + QUADVIEW_SYNC_SCRIPT;
|
|
36
|
+
return html.slice(0, closeBody) + QUADVIEW_SYNC_SCRIPT + html.slice(closeBody);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function decompressBody(buffer, encoding) {
|
|
40
|
+
const enc = (encoding || '').toLowerCase().split(',')[0].trim();
|
|
41
|
+
if (enc === 'gzip') return zlib.gunzipSync(buffer);
|
|
42
|
+
if (enc === 'deflate') return zlib.inflateSync(buffer);
|
|
43
|
+
if (enc === 'br') return zlib.brotliDecompressSync(buffer);
|
|
44
|
+
return buffer;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function rewriteFrameHeaders(headers, proxyBaseUrl, targetOrigin) {
|
|
48
|
+
const out = {};
|
|
49
|
+
for (const k of Object.keys(headers)) {
|
|
50
|
+
out[k] = headers[k];
|
|
51
|
+
}
|
|
52
|
+
delete out['x-frame-options'];
|
|
53
|
+
delete out['X-Frame-Options'];
|
|
54
|
+
if (out['content-security-policy']) {
|
|
55
|
+
out['content-security-policy'] = out['content-security-policy']
|
|
56
|
+
.replace(/frame-ancestors[^;]*;?/gi, 'frame-ancestors *;');
|
|
57
|
+
}
|
|
58
|
+
if (out['Content-Security-Policy']) {
|
|
59
|
+
out['Content-Security-Policy'] = out['Content-Security-Policy']
|
|
60
|
+
.replace(/frame-ancestors[^;]*;?/gi, 'frame-ancestors *;');
|
|
61
|
+
}
|
|
62
|
+
if (out['location'] || out['Location']) {
|
|
63
|
+
const loc = out['location'] || out['Location'];
|
|
64
|
+
if (loc.startsWith(targetOrigin) || loc.startsWith('/')) {
|
|
65
|
+
const p = loc.startsWith('/') ? loc : new URL(loc).pathname + new URL(loc).search;
|
|
66
|
+
out['location'] = proxyBaseUrl.replace(/\/$/, '') + (p === '/' ? '' : p);
|
|
67
|
+
delete out['Location'];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function proxyRequest(targetUrl, pathname, clientReq, clientRes, wrapperPort) {
|
|
74
|
+
const target = new URL(pathname || '/', targetUrl);
|
|
75
|
+
const isHttps = target.protocol === 'https:';
|
|
76
|
+
const lib = isHttps ? https : http;
|
|
77
|
+
const options = {
|
|
78
|
+
hostname: target.hostname,
|
|
79
|
+
port: target.port || (isHttps ? 443 : 80),
|
|
80
|
+
path: target.pathname + target.search,
|
|
81
|
+
method: clientReq.method,
|
|
82
|
+
headers: { ...clientReq.headers },
|
|
83
|
+
};
|
|
84
|
+
delete options.headers.host;
|
|
85
|
+
|
|
86
|
+
const proxyReq = lib.request(options, (proxyRes) => {
|
|
87
|
+
const contentType = (proxyRes.headers['content-type'] || proxyRes.headers['Content-Type'] || '').toLowerCase();
|
|
88
|
+
const isHtml = contentType.indexOf('text/html') !== -1;
|
|
89
|
+
|
|
90
|
+
if (isHtml) {
|
|
91
|
+
const chunks = [];
|
|
92
|
+
proxyRes.on('data', (chunk) => chunks.push(chunk));
|
|
93
|
+
proxyRes.on('end', () => {
|
|
94
|
+
let raw = Buffer.concat(chunks);
|
|
95
|
+
const encoding = proxyRes.headers['content-encoding'] || proxyRes.headers['Content-Encoding'];
|
|
96
|
+
raw = decompressBody(raw, encoding);
|
|
97
|
+
let body = raw.toString('utf8');
|
|
98
|
+
body = injectSyncScript(body);
|
|
99
|
+
const targetOrigin = new URL(targetUrl).origin;
|
|
100
|
+
const proxyBaseUrl = `http://127.0.0.1:${clientReq.socket.server.address().port}${PROXY_PREFIX}/`;
|
|
101
|
+
const headers = rewriteFrameHeaders(proxyRes.headers, proxyBaseUrl, targetOrigin);
|
|
102
|
+
// We send uncompressed body; strip encoding so browser doesn't try to decode
|
|
103
|
+
delete headers['content-encoding'];
|
|
104
|
+
delete headers['Content-Encoding'];
|
|
105
|
+
delete headers['transfer-encoding'];
|
|
106
|
+
delete headers['Transfer-Encoding'];
|
|
107
|
+
headers['content-length'] = Buffer.byteLength(body, 'utf8');
|
|
108
|
+
clientRes.writeHead(proxyRes.statusCode, headers);
|
|
109
|
+
clientRes.end(body);
|
|
110
|
+
});
|
|
111
|
+
proxyRes.on('error', () => {
|
|
112
|
+
clientRes.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
113
|
+
clientRes.end('Bad Gateway');
|
|
114
|
+
});
|
|
115
|
+
} else {
|
|
116
|
+
clientRes.writeHead(proxyRes.statusCode, proxyRes.headers);
|
|
117
|
+
proxyRes.pipe(clientRes);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
proxyReq.on('error', (err) => {
|
|
122
|
+
clientRes.writeHead(502, { 'Content-Type': 'text/plain' });
|
|
123
|
+
clientRes.end('Bad Gateway: ' + err.message);
|
|
124
|
+
});
|
|
125
|
+
clientReq.pipe(proxyReq);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function start(port, targetUrl) {
|
|
129
|
+
const proxyBaseUrl = `http://127.0.0.1:${port}${PROXY_PREFIX}/`;
|
|
130
|
+
|
|
131
|
+
const server = http.createServer((req, res) => {
|
|
132
|
+
const url = req.url || '/';
|
|
133
|
+
const pathname = url.split('?')[0];
|
|
134
|
+
|
|
135
|
+
if (req.method === 'GET' && (pathname === '/' || pathname === '')) {
|
|
136
|
+
const html = getWrapperHtml(proxyBaseUrl);
|
|
137
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
138
|
+
res.end(html);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Proxy everything else so framework assets work: /_next/*, /static/*, /@vite/*, etc.
|
|
143
|
+
const targetPath =
|
|
144
|
+
pathname === PROXY_PREFIX || pathname.startsWith(PROXY_PREFIX + '/')
|
|
145
|
+
? pathname.slice(PROXY_PREFIX.length) || '/'
|
|
146
|
+
: pathname;
|
|
147
|
+
proxyRequest(targetUrl, targetPath, req, res, port);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
server.listen(port, '127.0.0.1');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { start };
|
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>QuadView</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
|
10
|
+
<style>
|
|
11
|
+
:root {
|
|
12
|
+
--qv-bg: #f8fafc;
|
|
13
|
+
--qv-bg-gradient: linear-gradient(to bottom right, #f9fafb 0%, rgba(239, 246, 255, 0.3) 50%, rgba(250, 245, 255, 0.3) 100%);
|
|
14
|
+
--qv-surface: rgba(255, 255, 255, 0.8);
|
|
15
|
+
--qv-surface-pane: rgba(255, 255, 255, 0.4);
|
|
16
|
+
--qv-border: rgba(0, 0, 0, 0.08);
|
|
17
|
+
--qv-text: #111827;
|
|
18
|
+
--qv-text-muted: #6b7280;
|
|
19
|
+
--qv-accent: #3b82f6;
|
|
20
|
+
--qv-accent-end: #8b5cf6;
|
|
21
|
+
--qv-shadow: 0 12px 40px rgba(0, 0, 0, 0.08);
|
|
22
|
+
--qv-transition: 200ms ease;
|
|
23
|
+
--qv-radius: 0.625rem;
|
|
24
|
+
--qv-radius-full: 9999px;
|
|
25
|
+
}
|
|
26
|
+
.dark {
|
|
27
|
+
--qv-bg: #030712;
|
|
28
|
+
--qv-bg-gradient: linear-gradient(to bottom right, #030712 0%, rgba(30, 58, 138, 0.2) 50%, rgba(59, 7, 100, 0.2) 100%);
|
|
29
|
+
--qv-surface: rgba(3, 7, 18, 0.8);
|
|
30
|
+
--qv-surface-pane: rgba(17, 24, 39, 0.4);
|
|
31
|
+
--qv-border: rgba(255, 255, 255, 0.1);
|
|
32
|
+
--qv-text: #f9fafb;
|
|
33
|
+
--qv-text-muted: #9ca3af;
|
|
34
|
+
--qv-accent: #60a5fa;
|
|
35
|
+
--qv-accent-end: #a78bfa;
|
|
36
|
+
--qv-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
|
|
37
|
+
}
|
|
38
|
+
* { box-sizing: border-box; }
|
|
39
|
+
body {
|
|
40
|
+
margin: 0;
|
|
41
|
+
font-family: 'Plus Jakarta Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
42
|
+
background: var(--qv-bg);
|
|
43
|
+
background-image: var(--qv-bg-gradient);
|
|
44
|
+
color: var(--qv-text);
|
|
45
|
+
min-height: 100vh;
|
|
46
|
+
transition: background var(--qv-transition), color var(--qv-transition);
|
|
47
|
+
}
|
|
48
|
+
.header {
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
justify-content: space-between;
|
|
52
|
+
gap: 1rem;
|
|
53
|
+
padding: 0.5rem 1rem;
|
|
54
|
+
background: var(--qv-surface);
|
|
55
|
+
backdrop-filter: blur(24px);
|
|
56
|
+
-webkit-backdrop-filter: blur(24px);
|
|
57
|
+
border-bottom: 1px solid var(--qv-border);
|
|
58
|
+
position: sticky;
|
|
59
|
+
top: 0;
|
|
60
|
+
z-index: 50;
|
|
61
|
+
}
|
|
62
|
+
.header-logo {
|
|
63
|
+
display: flex;
|
|
64
|
+
align-items: center;
|
|
65
|
+
gap: 0.5rem;
|
|
66
|
+
}
|
|
67
|
+
.header-logo__icon {
|
|
68
|
+
width: 28px;
|
|
69
|
+
height: 28px;
|
|
70
|
+
border-radius: 10px;
|
|
71
|
+
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 50%, #6366f1 100%);
|
|
72
|
+
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.35);
|
|
73
|
+
display: flex;
|
|
74
|
+
align-items: center;
|
|
75
|
+
justify-content: center;
|
|
76
|
+
color: #fff;
|
|
77
|
+
font-size: 14px;
|
|
78
|
+
}
|
|
79
|
+
.header h1 { margin: 0; font-size: 0.9rem; font-weight: 600; }
|
|
80
|
+
.header-actions {
|
|
81
|
+
display: flex;
|
|
82
|
+
align-items: center;
|
|
83
|
+
gap: 0.5rem;
|
|
84
|
+
}
|
|
85
|
+
.toggle-wrap {
|
|
86
|
+
display: flex;
|
|
87
|
+
align-items: center;
|
|
88
|
+
gap: 0.35rem;
|
|
89
|
+
padding: 0.35rem 0.6rem;
|
|
90
|
+
border-radius: var(--qv-radius-full);
|
|
91
|
+
background: rgba(243, 244, 246, 0.8);
|
|
92
|
+
border: 1px solid var(--qv-border);
|
|
93
|
+
backdrop-filter: blur(12px);
|
|
94
|
+
font-size: 11px;
|
|
95
|
+
font-weight: 500;
|
|
96
|
+
color: var(--qv-text-muted);
|
|
97
|
+
}
|
|
98
|
+
.dark .toggle-wrap { background: rgba(31, 41, 55, 0.8); }
|
|
99
|
+
.toggle-wrap input { accent-color: var(--qv-accent); cursor: pointer; }
|
|
100
|
+
.toggle-wrap label { cursor: pointer; user-select: none; }
|
|
101
|
+
.btn-reset {
|
|
102
|
+
padding: 0.35rem 0.6rem;
|
|
103
|
+
font-size: 11px;
|
|
104
|
+
font-weight: 500;
|
|
105
|
+
background: rgba(243, 244, 246, 0.8);
|
|
106
|
+
color: var(--qv-text-muted);
|
|
107
|
+
border: 1px solid var(--qv-border);
|
|
108
|
+
border-radius: var(--qv-radius-full);
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
transition: background var(--qv-transition), color var(--qv-transition), border-color var(--qv-transition);
|
|
111
|
+
backdrop-filter: blur(12px);
|
|
112
|
+
display: inline-flex;
|
|
113
|
+
align-items: center;
|
|
114
|
+
gap: 0.35rem;
|
|
115
|
+
}
|
|
116
|
+
.dark .btn-reset { background: rgba(31, 41, 55, 0.8); }
|
|
117
|
+
.btn-reset:hover {
|
|
118
|
+
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
|
119
|
+
color: #fff;
|
|
120
|
+
border-color: transparent;
|
|
121
|
+
}
|
|
122
|
+
.sync-badge {
|
|
123
|
+
display: inline-flex;
|
|
124
|
+
align-items: center;
|
|
125
|
+
gap: 0.35rem;
|
|
126
|
+
padding: 0.35rem 0.6rem;
|
|
127
|
+
border-radius: var(--qv-radius-full);
|
|
128
|
+
font-size: 11px;
|
|
129
|
+
font-weight: 500;
|
|
130
|
+
background: rgba(59, 130, 246, 0.1);
|
|
131
|
+
border: 1px solid rgba(59, 130, 246, 0.3);
|
|
132
|
+
color: var(--qv-accent);
|
|
133
|
+
}
|
|
134
|
+
.dark .sync-badge { background: rgba(59, 130, 246, 0.2); color: #93c5fd; }
|
|
135
|
+
.sync-badge__dot {
|
|
136
|
+
width: 6px;
|
|
137
|
+
height: 6px;
|
|
138
|
+
border-radius: 50%;
|
|
139
|
+
background: var(--qv-accent);
|
|
140
|
+
animation: qv-pulse 1.5s ease-in-out infinite;
|
|
141
|
+
}
|
|
142
|
+
@keyframes qv-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } }
|
|
143
|
+
.btn-theme {
|
|
144
|
+
padding: 0.35rem;
|
|
145
|
+
border-radius: var(--qv-radius-full);
|
|
146
|
+
background: rgba(243, 244, 246, 0.8);
|
|
147
|
+
border: 1px solid var(--qv-border);
|
|
148
|
+
color: var(--qv-text-muted);
|
|
149
|
+
cursor: pointer;
|
|
150
|
+
transition: background var(--qv-transition), color var(--qv-transition);
|
|
151
|
+
backdrop-filter: blur(12px);
|
|
152
|
+
}
|
|
153
|
+
.dark .btn-theme { background: rgba(31, 41, 55, 0.8); }
|
|
154
|
+
.btn-theme:hover { color: var(--qv-text); }
|
|
155
|
+
.grid {
|
|
156
|
+
display: grid;
|
|
157
|
+
grid-template-columns: 1fr 1fr;
|
|
158
|
+
grid-template-rows: 1fr 1fr;
|
|
159
|
+
gap: 8px;
|
|
160
|
+
padding: 8px 12px 12px;
|
|
161
|
+
height: calc(100vh - 48px);
|
|
162
|
+
}
|
|
163
|
+
.grid--enlarged .pane:not(.pane--enlarged) {
|
|
164
|
+
display: none;
|
|
165
|
+
}
|
|
166
|
+
.grid--enlarged .pane.pane--enlarged {
|
|
167
|
+
grid-column: 1 / -1;
|
|
168
|
+
grid-row: 1 / -1;
|
|
169
|
+
}
|
|
170
|
+
.pane {
|
|
171
|
+
display: flex;
|
|
172
|
+
flex-direction: column;
|
|
173
|
+
min-height: 0;
|
|
174
|
+
background: var(--qv-surface-pane);
|
|
175
|
+
backdrop-filter: blur(24px);
|
|
176
|
+
-webkit-backdrop-filter: blur(24px);
|
|
177
|
+
border-radius: 1rem;
|
|
178
|
+
border: 1px solid var(--qv-border);
|
|
179
|
+
overflow: hidden;
|
|
180
|
+
transition: box-shadow var(--qv-transition);
|
|
181
|
+
}
|
|
182
|
+
.pane-header {
|
|
183
|
+
display: flex;
|
|
184
|
+
align-items: center;
|
|
185
|
+
gap: 8px;
|
|
186
|
+
padding: 6px 10px;
|
|
187
|
+
background: transparent;
|
|
188
|
+
flex-shrink: 0;
|
|
189
|
+
border-bottom: 1px solid var(--qv-border);
|
|
190
|
+
}
|
|
191
|
+
.pane-header select {
|
|
192
|
+
flex: 1;
|
|
193
|
+
min-width: 0;
|
|
194
|
+
padding: 5px 10px;
|
|
195
|
+
font-size: 11px;
|
|
196
|
+
font-weight: 500;
|
|
197
|
+
background: rgba(243, 244, 246, 0.8);
|
|
198
|
+
color: var(--qv-text);
|
|
199
|
+
border: 1px solid var(--qv-border);
|
|
200
|
+
border-radius: var(--qv-radius-full);
|
|
201
|
+
cursor: pointer;
|
|
202
|
+
transition: border-color var(--qv-transition), background var(--qv-transition);
|
|
203
|
+
}
|
|
204
|
+
.dark .pane-header select { background: rgba(31, 41, 55, 0.8); }
|
|
205
|
+
.pane-header select:focus { outline: none; border-color: var(--qv-accent); }
|
|
206
|
+
.btn-enlarge {
|
|
207
|
+
padding: 6px;
|
|
208
|
+
font-size: 12px;
|
|
209
|
+
background: rgba(243, 244, 246, 0.8);
|
|
210
|
+
color: var(--qv-text-muted);
|
|
211
|
+
border: 1px solid var(--qv-border);
|
|
212
|
+
border-radius: var(--qv-radius-full);
|
|
213
|
+
cursor: pointer;
|
|
214
|
+
transition: background var(--qv-transition), color var(--qv-transition), border-color var(--qv-transition);
|
|
215
|
+
flex-shrink: 0;
|
|
216
|
+
}
|
|
217
|
+
.dark .btn-enlarge { background: rgba(31, 41, 55, 0.8); }
|
|
218
|
+
.btn-enlarge:hover {
|
|
219
|
+
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
|
220
|
+
color: #fff;
|
|
221
|
+
border-color: transparent;
|
|
222
|
+
}
|
|
223
|
+
.pane--enlarged .btn-enlarge {
|
|
224
|
+
background: linear-gradient(90deg, #3b82f6, #8b5cf6);
|
|
225
|
+
color: #fff;
|
|
226
|
+
border-color: transparent;
|
|
227
|
+
}
|
|
228
|
+
.resolution-badge {
|
|
229
|
+
font-size: 9px;
|
|
230
|
+
font-weight: 500;
|
|
231
|
+
font-family: ui-monospace, monospace;
|
|
232
|
+
white-space: nowrap;
|
|
233
|
+
padding: 2px 8px;
|
|
234
|
+
border-radius: var(--qv-radius-full);
|
|
235
|
+
background: linear-gradient(90deg, rgba(59, 130, 246, 0.1), rgba(139, 92, 246, 0.1));
|
|
236
|
+
border: 1px solid rgba(59, 130, 246, 0.2);
|
|
237
|
+
color: var(--qv-accent);
|
|
238
|
+
}
|
|
239
|
+
.dark .resolution-badge {
|
|
240
|
+
background: linear-gradient(90deg, rgba(59, 130, 246, 0.2), rgba(139, 92, 246, 0.2));
|
|
241
|
+
border-color: rgba(59, 130, 246, 0.3);
|
|
242
|
+
color: #93c5fd;
|
|
243
|
+
}
|
|
244
|
+
.pane-viewport { flex: 1; min-height: 0; display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.5); overflow: hidden; }
|
|
245
|
+
.dark .pane-viewport { background: rgba(17, 24, 39, 0.5); }
|
|
246
|
+
.pane-viewport--hardware { display: none; }
|
|
247
|
+
.quadview--hardware .pane-viewport--minimal { display: none; }
|
|
248
|
+
.quadview--hardware .pane-viewport--hardware { display: flex; }
|
|
249
|
+
.iframe-wrap {
|
|
250
|
+
width: 100%;
|
|
251
|
+
height: 100%;
|
|
252
|
+
display: flex;
|
|
253
|
+
align-items: center;
|
|
254
|
+
justify-content: center;
|
|
255
|
+
overflow: hidden;
|
|
256
|
+
}
|
|
257
|
+
.iframe-scaled-wrap {
|
|
258
|
+
overflow: hidden;
|
|
259
|
+
flex-shrink: 0;
|
|
260
|
+
}
|
|
261
|
+
.iframe-wrap iframe { border: none; background: #fff; display: block; }
|
|
262
|
+
.minimal-wrap .iframe-wrap iframe { box-shadow: 0 0 0 1px var(--qv-border); }
|
|
263
|
+
|
|
264
|
+
/* ---------- Device shells (hardware mode) ---------- */
|
|
265
|
+
.device-shell {
|
|
266
|
+
position: relative;
|
|
267
|
+
display: flex;
|
|
268
|
+
flex-direction: column;
|
|
269
|
+
align-items: center;
|
|
270
|
+
justify-content: center;
|
|
271
|
+
transition: transform var(--qv-transition);
|
|
272
|
+
}
|
|
273
|
+
.device-shell__frame {
|
|
274
|
+
position: relative;
|
|
275
|
+
display: flex;
|
|
276
|
+
flex-direction: column;
|
|
277
|
+
align-items: center;
|
|
278
|
+
box-shadow: var(--qv-shadow);
|
|
279
|
+
border-radius: 12px;
|
|
280
|
+
}
|
|
281
|
+
.device-shell__screen-outer {
|
|
282
|
+
position: relative;
|
|
283
|
+
overflow: hidden;
|
|
284
|
+
background: #0a0a0a;
|
|
285
|
+
}
|
|
286
|
+
.device-shell__screen-inner {
|
|
287
|
+
position: relative;
|
|
288
|
+
overflow: hidden;
|
|
289
|
+
background: #fff;
|
|
290
|
+
}
|
|
291
|
+
.dark .device-shell__screen-inner { background: #111827; }
|
|
292
|
+
.device-shell__screen-inner iframe {
|
|
293
|
+
display: block;
|
|
294
|
+
border: none;
|
|
295
|
+
background: #fff;
|
|
296
|
+
}
|
|
297
|
+
.device-shell__chin,
|
|
298
|
+
.device-shell__stand {
|
|
299
|
+
display: none;
|
|
300
|
+
background: linear-gradient(180deg, rgba(55, 65, 81, 0.95) 0%, rgba(31, 41, 55, 0.98) 100%);
|
|
301
|
+
border: 1px solid var(--qv-border);
|
|
302
|
+
}
|
|
303
|
+
.device-shell--desktop .device-shell__chin,
|
|
304
|
+
.device-shell--desktop .device-shell__stand { display: block; }
|
|
305
|
+
.device-shell__camera {
|
|
306
|
+
display: none;
|
|
307
|
+
position: absolute;
|
|
308
|
+
top: 8px;
|
|
309
|
+
left: 50%;
|
|
310
|
+
transform: translateX(-50%);
|
|
311
|
+
width: 6px;
|
|
312
|
+
height: 6px;
|
|
313
|
+
background: #0a0a0a;
|
|
314
|
+
border-radius: 50%;
|
|
315
|
+
z-index: 1;
|
|
316
|
+
pointer-events: none;
|
|
317
|
+
}
|
|
318
|
+
.device-shell--laptop .device-shell__camera { display: block; }
|
|
319
|
+
|
|
320
|
+
/* Phone: rounded body, subtle gradient */
|
|
321
|
+
.device-shell--phone .device-shell__frame {
|
|
322
|
+
padding: 12px;
|
|
323
|
+
background: linear-gradient(145deg, rgba(75, 85, 99, 0.9) 0%, rgba(31, 41, 55, 0.95) 50%, rgba(17, 24, 39, 0.98) 100%);
|
|
324
|
+
border-radius: 36px;
|
|
325
|
+
box-shadow: var(--qv-shadow), inset 0 1px 0 rgba(255,255,255,0.06);
|
|
326
|
+
}
|
|
327
|
+
.device-shell--phone .device-shell__screen-outer {
|
|
328
|
+
border-radius: 28px;
|
|
329
|
+
padding: 8px;
|
|
330
|
+
background: #0a0a0a;
|
|
331
|
+
}
|
|
332
|
+
.device-shell--phone .device-shell__screen-inner {
|
|
333
|
+
border-radius: 22px;
|
|
334
|
+
overflow: hidden;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/* Tablet: slim bezel, rounded */
|
|
338
|
+
.device-shell--tablet .device-shell__frame {
|
|
339
|
+
padding: 10px;
|
|
340
|
+
background: linear-gradient(145deg, rgba(55, 65, 81, 0.9) 0%, rgba(31, 41, 55, 0.95) 100%);
|
|
341
|
+
border-radius: 16px;
|
|
342
|
+
box-shadow: var(--qv-shadow);
|
|
343
|
+
}
|
|
344
|
+
.device-shell--tablet .device-shell__screen-outer {
|
|
345
|
+
border-radius: 10px;
|
|
346
|
+
padding: 6px;
|
|
347
|
+
background: #0a0a0a;
|
|
348
|
+
}
|
|
349
|
+
.device-shell--tablet .device-shell__screen-inner { border-radius: 6px; }
|
|
350
|
+
|
|
351
|
+
/* Laptop: screen only, thin bezel, camera */
|
|
352
|
+
.device-shell--laptop .device-shell__frame {
|
|
353
|
+
padding: 8px;
|
|
354
|
+
background: linear-gradient(180deg, rgba(31, 41, 55, 0.95) 0%, rgba(17, 24, 39, 0.98) 100%);
|
|
355
|
+
border-radius: 10px;
|
|
356
|
+
box-shadow: var(--qv-shadow), inset 0 0 0 1px rgba(255,255,255,0.04);
|
|
357
|
+
}
|
|
358
|
+
.device-shell--laptop .device-shell__screen-outer {
|
|
359
|
+
border-radius: 4px;
|
|
360
|
+
padding: 4px;
|
|
361
|
+
background: #050506;
|
|
362
|
+
}
|
|
363
|
+
.device-shell--laptop .device-shell__screen-inner { border-radius: 2px; }
|
|
364
|
+
|
|
365
|
+
/* Desktop: monitor + chin + stand */
|
|
366
|
+
.device-shell--desktop .device-shell__frame {
|
|
367
|
+
flex-direction: column;
|
|
368
|
+
padding: 10px 10px 0;
|
|
369
|
+
background: linear-gradient(180deg, rgba(55, 65, 81, 0.9) 0%, rgba(31, 41, 55, 0.95) 100%);
|
|
370
|
+
border-radius: 10px 10px 0 0;
|
|
371
|
+
box-shadow: var(--qv-shadow);
|
|
372
|
+
}
|
|
373
|
+
.device-shell--desktop .device-shell__screen-outer {
|
|
374
|
+
border-radius: 4px;
|
|
375
|
+
padding: 6px;
|
|
376
|
+
background: #0a0a0a;
|
|
377
|
+
}
|
|
378
|
+
.device-shell--desktop .device-shell__screen-inner { border-radius: 2px; }
|
|
379
|
+
.device-shell--desktop .device-shell__chin {
|
|
380
|
+
width: 100%;
|
|
381
|
+
height: 24px;
|
|
382
|
+
border-radius: 0 0 6px 6px;
|
|
383
|
+
border-top: none;
|
|
384
|
+
margin-top: -1px;
|
|
385
|
+
}
|
|
386
|
+
.device-shell--desktop .device-shell__stand {
|
|
387
|
+
width: 80px;
|
|
388
|
+
min-width: 60px;
|
|
389
|
+
height: 14px;
|
|
390
|
+
border-radius: 0 0 4px 4px;
|
|
391
|
+
border-top: none;
|
|
392
|
+
margin-top: -1px;
|
|
393
|
+
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
|
394
|
+
}
|
|
395
|
+
.device-shell--desktop .device-shell__frame {
|
|
396
|
+
align-items: center;
|
|
397
|
+
}
|
|
398
|
+
.device-shell--desktop .device-shell__chin,
|
|
399
|
+
.device-shell--desktop .device-shell__stand {
|
|
400
|
+
align-self: center;
|
|
401
|
+
}
|
|
402
|
+
</style>
|
|
403
|
+
</head>
|
|
404
|
+
<body class="quadview--minimal" id="root">
|
|
405
|
+
<header class="header">
|
|
406
|
+
<div class="header-logo">
|
|
407
|
+
<div class="header-logo__icon" aria-hidden="true"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg></div>
|
|
408
|
+
<h1>QuadView</h1>
|
|
409
|
+
</div>
|
|
410
|
+
<div class="header-actions">
|
|
411
|
+
<div class="toggle-wrap">
|
|
412
|
+
<input type="checkbox" id="sync" aria-checked="true">
|
|
413
|
+
<label for="sync">Sync</label>
|
|
414
|
+
</div>
|
|
415
|
+
<div class="toggle-wrap">
|
|
416
|
+
<input type="checkbox" id="hardware-frames" aria-checked="false">
|
|
417
|
+
<label for="hardware-frames">Frames</label>
|
|
418
|
+
</div>
|
|
419
|
+
<button type="button" class="btn-reset" id="reset">Reset</button>
|
|
420
|
+
<div class="sync-badge" id="sync-badge" aria-hidden="true" style="display: none;">
|
|
421
|
+
<span class="sync-badge__dot"></span>
|
|
422
|
+
<span>Synced</span>
|
|
423
|
+
</div>
|
|
424
|
+
<button type="button" class="btn-theme" id="theme-toggle" title="Toggle dark mode" aria-label="Toggle dark mode"><span id="theme-icon">☾</span></button>
|
|
425
|
+
</div>
|
|
426
|
+
</header>
|
|
427
|
+
<div class="grid" id="grid">
|
|
428
|
+
<div class="pane" data-pane="0">
|
|
429
|
+
<div class="pane-header">
|
|
430
|
+
<select class="device-select" aria-label="Device for viewport 1"></select>
|
|
431
|
+
<span class="resolution-badge" aria-hidden="true" data-resolution></span>
|
|
432
|
+
<button type="button" class="btn-enlarge" title="Enlarge this view" aria-label="Enlarge viewport 1" data-enlarge>Enlarge</button>
|
|
433
|
+
</div>
|
|
434
|
+
<div class="pane-viewport pane-viewport--minimal">
|
|
435
|
+
<div class="iframe-wrap minimal-wrap"><div class="iframe-scaled-wrap"><iframe class="viewport" title="Viewport 1"></iframe></div></div>
|
|
436
|
+
</div>
|
|
437
|
+
<div class="pane-viewport pane-viewport--hardware">
|
|
438
|
+
<div class="device-shell device-shell--phone" data-shell>
|
|
439
|
+
<div class="device-shell__frame">
|
|
440
|
+
<div class="device-shell__screen-outer">
|
|
441
|
+
<div class="device-shell__camera" aria-hidden="true"></div>
|
|
442
|
+
<div class="device-shell__screen-inner"><iframe class="viewport viewport-hardware" title="Viewport 1"></iframe></div>
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
<div class="device-shell__chin"></div>
|
|
446
|
+
<div class="device-shell__stand"></div>
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
<div class="pane" data-pane="1">
|
|
451
|
+
<div class="pane-header">
|
|
452
|
+
<select class="device-select" aria-label="Device for viewport 2"></select>
|
|
453
|
+
<span class="resolution-badge" data-resolution></span>
|
|
454
|
+
<button type="button" class="btn-enlarge" title="Enlarge this view" aria-label="Enlarge viewport 2" data-enlarge>Enlarge</button>
|
|
455
|
+
</div>
|
|
456
|
+
<div class="pane-viewport pane-viewport--minimal">
|
|
457
|
+
<div class="iframe-wrap minimal-wrap"><div class="iframe-scaled-wrap"><iframe class="viewport" title="Viewport 2"></iframe></div></div>
|
|
458
|
+
</div>
|
|
459
|
+
<div class="pane-viewport pane-viewport--hardware">
|
|
460
|
+
<div class="device-shell device-shell--phone" data-shell>
|
|
461
|
+
<div class="device-shell__frame">
|
|
462
|
+
<div class="device-shell__screen-outer">
|
|
463
|
+
<div class="device-shell__camera"></div>
|
|
464
|
+
<div class="device-shell__screen-inner"><iframe class="viewport viewport-hardware" title="Viewport 2"></iframe></div>
|
|
465
|
+
</div>
|
|
466
|
+
</div>
|
|
467
|
+
<div class="device-shell__chin"></div>
|
|
468
|
+
<div class="device-shell__stand"></div>
|
|
469
|
+
</div>
|
|
470
|
+
</div>
|
|
471
|
+
</div>
|
|
472
|
+
<div class="pane" data-pane="2">
|
|
473
|
+
<div class="pane-header">
|
|
474
|
+
<select class="device-select" aria-label="Device for viewport 3"></select>
|
|
475
|
+
<span class="resolution-badge" data-resolution></span>
|
|
476
|
+
<button type="button" class="btn-enlarge" title="Enlarge this view" aria-label="Enlarge viewport 3" data-enlarge>Enlarge</button>
|
|
477
|
+
</div>
|
|
478
|
+
<div class="pane-viewport pane-viewport--minimal">
|
|
479
|
+
<div class="iframe-wrap minimal-wrap"><div class="iframe-scaled-wrap"><iframe class="viewport" title="Viewport 3"></iframe></div></div>
|
|
480
|
+
</div>
|
|
481
|
+
<div class="pane-viewport pane-viewport--hardware">
|
|
482
|
+
<div class="device-shell device-shell--phone" data-shell>
|
|
483
|
+
<div class="device-shell__frame">
|
|
484
|
+
<div class="device-shell__screen-outer">
|
|
485
|
+
<div class="device-shell__camera"></div>
|
|
486
|
+
<div class="device-shell__screen-inner"><iframe class="viewport viewport-hardware" title="Viewport 3"></iframe></div>
|
|
487
|
+
</div>
|
|
488
|
+
</div>
|
|
489
|
+
<div class="device-shell__chin"></div>
|
|
490
|
+
<div class="device-shell__stand"></div>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
</div>
|
|
494
|
+
<div class="pane" data-pane="3">
|
|
495
|
+
<div class="pane-header">
|
|
496
|
+
<select class="device-select" aria-label="Device for viewport 4"></select>
|
|
497
|
+
<span class="resolution-badge" data-resolution></span>
|
|
498
|
+
<button type="button" class="btn-enlarge" title="Enlarge this view" aria-label="Enlarge viewport 4" data-enlarge>Enlarge</button>
|
|
499
|
+
</div>
|
|
500
|
+
<div class="pane-viewport pane-viewport--minimal">
|
|
501
|
+
<div class="iframe-wrap minimal-wrap"><div class="iframe-scaled-wrap"><iframe class="viewport" title="Viewport 4"></iframe></div></div>
|
|
502
|
+
</div>
|
|
503
|
+
<div class="pane-viewport pane-viewport--hardware">
|
|
504
|
+
<div class="device-shell device-shell--phone" data-shell>
|
|
505
|
+
<div class="device-shell__frame">
|
|
506
|
+
<div class="device-shell__screen-outer">
|
|
507
|
+
<div class="device-shell__camera"></div>
|
|
508
|
+
<div class="device-shell__screen-inner"><iframe class="viewport viewport-hardware" title="Viewport 4"></iframe></div>
|
|
509
|
+
</div>
|
|
510
|
+
</div>
|
|
511
|
+
<div class="device-shell__chin"></div>
|
|
512
|
+
<div class="device-shell__stand"></div>
|
|
513
|
+
</div>
|
|
514
|
+
</div>
|
|
515
|
+
</div>
|
|
516
|
+
</div>
|
|
517
|
+
<script>
|
|
518
|
+
(function() {
|
|
519
|
+
window.TARGET_URL = "{{TARGET_URL_ESCAPED}}";
|
|
520
|
+
window.DEVICES = {{DEVICES_JSON}};
|
|
521
|
+
})();
|
|
522
|
+
</script>
|
|
523
|
+
<script>
|
|
524
|
+
(function() {
|
|
525
|
+
const STORAGE_KEY = 'quadview-devices';
|
|
526
|
+
const STORAGE_HARDWARE = 'quadview-hardware-frames';
|
|
527
|
+
const STORAGE_SYNC = 'quadview-sync';
|
|
528
|
+
const STORAGE_THEME = 'quadview-theme';
|
|
529
|
+
const PANE_COUNT = 4;
|
|
530
|
+
var enlargedPaneIndex = null;
|
|
531
|
+
|
|
532
|
+
function applyTheme(dark) {
|
|
533
|
+
if (dark) {
|
|
534
|
+
document.documentElement.classList.add('dark');
|
|
535
|
+
var icon = document.getElementById('theme-icon');
|
|
536
|
+
if (icon) icon.textContent = '\u263C';
|
|
537
|
+
} else {
|
|
538
|
+
document.documentElement.classList.remove('dark');
|
|
539
|
+
var icon = document.getElementById('theme-icon');
|
|
540
|
+
if (icon) icon.textContent = '\u263E';
|
|
541
|
+
}
|
|
542
|
+
try { localStorage.setItem(STORAGE_THEME, dark ? 'dark' : 'light'); } catch (e) {}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
function updateSyncBadge() {
|
|
546
|
+
var sb = document.getElementById('sync-badge');
|
|
547
|
+
if (!sb) return;
|
|
548
|
+
try {
|
|
549
|
+
sb.style.display = localStorage.getItem(STORAGE_SYNC) !== '0' ? '' : 'none';
|
|
550
|
+
} catch (e) { sb.style.display = 'none'; }
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const devices = window.DEVICES || [];
|
|
554
|
+
const targetUrl = window.TARGET_URL || '';
|
|
555
|
+
|
|
556
|
+
function getAllViewportIframes() {
|
|
557
|
+
return Array.prototype.slice.call(document.querySelectorAll('iframe.viewport'));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function getVisibleViewportIframes() {
|
|
561
|
+
var root = document.getElementById('root');
|
|
562
|
+
var isHardware = root && root.classList.contains('quadview--hardware');
|
|
563
|
+
var selector = isHardware
|
|
564
|
+
? '.pane-viewport--hardware .device-shell__screen-inner iframe.viewport'
|
|
565
|
+
: '.pane-viewport--minimal .viewport';
|
|
566
|
+
var list = document.querySelectorAll(selector);
|
|
567
|
+
return Array.prototype.slice.call(list, 0, PANE_COUNT);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
var navSyncLock = false;
|
|
571
|
+
var lastScrollBroadcast = { x: -1, y: -1, t: 0 };
|
|
572
|
+
var scrollBroadcastMinInterval = 16;
|
|
573
|
+
function isSyncEnabled() {
|
|
574
|
+
try { return localStorage.getItem(STORAGE_SYNC) !== '0'; } catch (e) { return true; }
|
|
575
|
+
}
|
|
576
|
+
function setupSync() {
|
|
577
|
+
window.addEventListener('message', function(e) {
|
|
578
|
+
if (!isSyncEnabled() || !e.data || e.data.type !== 'quadview-scroll') return;
|
|
579
|
+
var x = e.data.x, y = e.data.y;
|
|
580
|
+
var now = Date.now();
|
|
581
|
+
var dx = Math.abs(x - lastScrollBroadcast.x), dy = Math.abs(y - lastScrollBroadcast.y);
|
|
582
|
+
if (dx < 0.5 && dy < 0.5 && (now - lastScrollBroadcast.t) < scrollBroadcastMinInterval) return;
|
|
583
|
+
lastScrollBroadcast = { x: x, y: y, t: now };
|
|
584
|
+
var iframes = getVisibleViewportIframes();
|
|
585
|
+
iframes.forEach(function(iframe) {
|
|
586
|
+
try {
|
|
587
|
+
if (iframe.contentWindow && iframe.contentWindow !== e.source) {
|
|
588
|
+
iframe.contentWindow.postMessage({ type: 'quadview-sync-scroll', x: x, y: y }, '*');
|
|
589
|
+
}
|
|
590
|
+
} catch (err) {}
|
|
591
|
+
});
|
|
592
|
+
});
|
|
593
|
+
function onIframeLoad(ev) {
|
|
594
|
+
if (!isSyncEnabled() || navSyncLock) return;
|
|
595
|
+
var iframe = ev.target;
|
|
596
|
+
var href;
|
|
597
|
+
try {
|
|
598
|
+
if (iframe.contentWindow && iframe.contentWindow.location) {
|
|
599
|
+
href = iframe.contentWindow.location.href;
|
|
600
|
+
}
|
|
601
|
+
} catch (err) { return; }
|
|
602
|
+
if (!href) return;
|
|
603
|
+
navSyncLock = true;
|
|
604
|
+
getAllViewportIframes().forEach(function(f) {
|
|
605
|
+
if (f !== iframe) f.src = href;
|
|
606
|
+
});
|
|
607
|
+
setTimeout(function() { navSyncLock = false; }, 600);
|
|
608
|
+
}
|
|
609
|
+
getAllViewportIframes().forEach(function(iframe) {
|
|
610
|
+
iframe.addEventListener('load', onIframeLoad);
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function groupDevices() {
|
|
615
|
+
const byGroup = {};
|
|
616
|
+
devices.forEach(function(d) {
|
|
617
|
+
if (!byGroup[d.group]) byGroup[d.group] = [];
|
|
618
|
+
byGroup[d.group].push(d);
|
|
619
|
+
});
|
|
620
|
+
const order = ['Phones', 'Tablets', 'Laptops', 'Desktops'];
|
|
621
|
+
return order.filter(function(g) { return byGroup[g]; }).map(function(g) { return { group: g, items: byGroup[g] }; });
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function buildSelect(selectEl) {
|
|
625
|
+
selectEl.innerHTML = '';
|
|
626
|
+
const grouped = groupDevices();
|
|
627
|
+
grouped.forEach(function(_group) {
|
|
628
|
+
const optgroup = document.createElement('optgroup');
|
|
629
|
+
optgroup.label = _group.group;
|
|
630
|
+
_group.items.forEach(function(dev) {
|
|
631
|
+
const opt = document.createElement('option');
|
|
632
|
+
opt.value = dev.id;
|
|
633
|
+
opt.textContent = dev.name + ' (' + dev.width + '×' + dev.height + ')';
|
|
634
|
+
optgroup.appendChild(opt);
|
|
635
|
+
});
|
|
636
|
+
selectEl.appendChild(optgroup);
|
|
637
|
+
});
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function getDeviceById(id) {
|
|
641
|
+
for (var i = 0; i < devices.length; i++) {
|
|
642
|
+
if (devices[i].id === id) return devices[i];
|
|
643
|
+
}
|
|
644
|
+
return devices[0] || null;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function getShellType(device) {
|
|
648
|
+
return (device && device.shellType) || 'phone';
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function applyDeviceToPane(paneIndex, deviceId) {
|
|
652
|
+
const pane = document.querySelector('.pane[data-pane="' + paneIndex + '"]');
|
|
653
|
+
if (!pane) return;
|
|
654
|
+
const select = pane.querySelector('.device-select');
|
|
655
|
+
const device = getDeviceById(deviceId);
|
|
656
|
+
if (!device) return;
|
|
657
|
+
select.value = device.id;
|
|
658
|
+
|
|
659
|
+
const shellType = getShellType(device);
|
|
660
|
+
const shell = pane.querySelector('[data-shell]');
|
|
661
|
+
if (shell) {
|
|
662
|
+
shell.className = 'device-shell device-shell--' + shellType;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const resolutionEl = pane.querySelector('[data-resolution]');
|
|
666
|
+
if (resolutionEl) resolutionEl.textContent = device.width + '×' + device.height;
|
|
667
|
+
|
|
668
|
+
const minimalWrap = pane.querySelector('.pane-viewport--minimal .iframe-wrap');
|
|
669
|
+
const minimalScaledWrap = pane.querySelector('.pane-viewport--minimal .iframe-scaled-wrap');
|
|
670
|
+
const minimalIframe = pane.querySelector('.pane-viewport--minimal .viewport');
|
|
671
|
+
const hardwareInner = pane.querySelector('.pane-viewport--hardware .device-shell__screen-inner');
|
|
672
|
+
const hardwareIframe = pane.querySelector('.pane-viewport--hardware .viewport');
|
|
673
|
+
|
|
674
|
+
var w = device.width;
|
|
675
|
+
var h = device.height;
|
|
676
|
+
var container = minimalWrap && minimalWrap.getBoundingClientRect();
|
|
677
|
+
var scale = 1;
|
|
678
|
+
if (container && container.width && container.height) {
|
|
679
|
+
var sx = container.width / w;
|
|
680
|
+
var sy = container.height / h;
|
|
681
|
+
scale = Math.min(sx, sy, 1);
|
|
682
|
+
}
|
|
683
|
+
if (minimalIframe && minimalScaledWrap) {
|
|
684
|
+
minimalScaledWrap.style.width = Math.round(w * scale) + 'px';
|
|
685
|
+
minimalScaledWrap.style.height = Math.round(h * scale) + 'px';
|
|
686
|
+
minimalIframe.style.width = w + 'px';
|
|
687
|
+
minimalIframe.style.height = h + 'px';
|
|
688
|
+
minimalIframe.style.transform = 'scale(' + scale + ')';
|
|
689
|
+
minimalIframe.style.transformOrigin = 'top left';
|
|
690
|
+
}
|
|
691
|
+
if (hardwareInner && hardwareIframe) {
|
|
692
|
+
hardwareInner.style.width = w + 'px';
|
|
693
|
+
hardwareInner.style.height = h + 'px';
|
|
694
|
+
hardwareIframe.style.width = w + 'px';
|
|
695
|
+
hardwareIframe.style.height = h + 'px';
|
|
696
|
+
var shellViewport = pane.querySelector('.pane-viewport--hardware');
|
|
697
|
+
var viewRect = shellViewport ? shellViewport.getBoundingClientRect() : { width: 400, height: 300 };
|
|
698
|
+
var shellScale = Math.min((viewRect.width || 400) / (w + 80), (viewRect.height || 300) / (h + 60), 1);
|
|
699
|
+
shell.style.transform = 'scale(' + shellScale + ')';
|
|
700
|
+
shell.style.transformOrigin = 'center center';
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function loadStored() {
|
|
705
|
+
var stored = null;
|
|
706
|
+
try {
|
|
707
|
+
var raw = localStorage.getItem(STORAGE_KEY);
|
|
708
|
+
if (raw) stored = JSON.parse(raw);
|
|
709
|
+
} catch (e) {}
|
|
710
|
+
var ids = stored && Array.isArray(stored) ? stored : [];
|
|
711
|
+
var defaultId = devices.length ? devices[0].id : '';
|
|
712
|
+
for (var p = 0; p < PANE_COUNT; p++) {
|
|
713
|
+
var id = ids[p] != null ? ids[p] : defaultId;
|
|
714
|
+
applyDeviceToPane(p, id);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function saveStored() {
|
|
719
|
+
var ids = [];
|
|
720
|
+
for (var p = 0; p < PANE_COUNT; p++) {
|
|
721
|
+
var pane = document.querySelector('.pane[data-pane="' + p + '"]');
|
|
722
|
+
var select = pane ? pane.querySelector('.device-select') : null;
|
|
723
|
+
ids.push(select ? select.value : (devices[0] ? devices[0].id : ''));
|
|
724
|
+
}
|
|
725
|
+
try {
|
|
726
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
|
|
727
|
+
} catch (e) {}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function setEnlargedPane(index) {
|
|
731
|
+
var grid = document.getElementById('grid');
|
|
732
|
+
if (!grid) return;
|
|
733
|
+
enlargedPaneIndex = index;
|
|
734
|
+
grid.classList.toggle('grid--enlarged', index !== null);
|
|
735
|
+
for (var p = 0; p < PANE_COUNT; p++) {
|
|
736
|
+
var pane = document.querySelector('.pane[data-pane="' + p + '"]');
|
|
737
|
+
var btn = pane ? pane.querySelector('.btn-enlarge') : null;
|
|
738
|
+
if (pane) pane.classList.toggle('pane--enlarged', p === index);
|
|
739
|
+
if (btn) {
|
|
740
|
+
btn.textContent = p === index ? 'Restore' : 'Enlarge';
|
|
741
|
+
btn.title = p === index ? 'Restore to grid' : 'Enlarge this view';
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
setTimeout(function() {
|
|
745
|
+
if (index !== null) {
|
|
746
|
+
var pane = document.querySelector('.pane[data-pane="' + index + '"]');
|
|
747
|
+
var select = pane ? pane.querySelector('.device-select') : null;
|
|
748
|
+
if (select && select.value) applyDeviceToPane(index, select.value);
|
|
749
|
+
} else {
|
|
750
|
+
for (var p = 0; p < PANE_COUNT; p++) {
|
|
751
|
+
var pane = document.querySelector('.pane[data-pane="' + p + '"]');
|
|
752
|
+
var select = pane ? pane.querySelector('.device-select') : null;
|
|
753
|
+
if (select && select.value) applyDeviceToPane(p, select.value);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}, 0);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function setHardwareMode(on) {
|
|
760
|
+
var root = document.getElementById('root');
|
|
761
|
+
if (root) {
|
|
762
|
+
root.classList.remove('quadview--minimal', 'quadview--hardware');
|
|
763
|
+
root.classList.add(on ? 'quadview--hardware' : 'quadview--minimal');
|
|
764
|
+
}
|
|
765
|
+
try {
|
|
766
|
+
localStorage.setItem(STORAGE_HARDWARE, on ? '1' : '0');
|
|
767
|
+
} catch (e) {}
|
|
768
|
+
setTimeout(function() {
|
|
769
|
+
for (var p = 0; p < PANE_COUNT; p++) {
|
|
770
|
+
var pane = document.querySelector('.pane[data-pane="' + p + '"]');
|
|
771
|
+
var select = pane ? pane.querySelector('.device-select') : null;
|
|
772
|
+
if (select && select.value) applyDeviceToPane(p, select.value);
|
|
773
|
+
}
|
|
774
|
+
}, 0);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function reset() {
|
|
778
|
+
try { localStorage.removeItem(STORAGE_KEY); } catch (e) {}
|
|
779
|
+
setEnlargedPane(null);
|
|
780
|
+
var defaultId = devices.length ? devices[0].id : '';
|
|
781
|
+
for (var p = 0; p < PANE_COUNT; p++) {
|
|
782
|
+
applyDeviceToPane(p, defaultId);
|
|
783
|
+
}
|
|
784
|
+
saveStored();
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function init() {
|
|
788
|
+
var theme = 'light';
|
|
789
|
+
try { theme = localStorage.getItem(STORAGE_THEME) || 'light'; } catch (e) {}
|
|
790
|
+
applyTheme(theme === 'dark');
|
|
791
|
+
var themeBtn = document.getElementById('theme-toggle');
|
|
792
|
+
if (themeBtn) {
|
|
793
|
+
themeBtn.addEventListener('click', function() {
|
|
794
|
+
var isDark = document.documentElement.classList.contains('dark');
|
|
795
|
+
applyTheme(!isDark);
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
var syncChecked = true;
|
|
800
|
+
try {
|
|
801
|
+
syncChecked = localStorage.getItem(STORAGE_SYNC) !== '0';
|
|
802
|
+
} catch (e) {}
|
|
803
|
+
updateSyncBadge();
|
|
804
|
+
var syncCb = document.getElementById('sync');
|
|
805
|
+
if (syncCb) {
|
|
806
|
+
syncCb.checked = syncChecked;
|
|
807
|
+
syncCb.setAttribute('aria-checked', syncChecked ? 'true' : 'false');
|
|
808
|
+
syncCb.addEventListener('change', function() {
|
|
809
|
+
var on = syncCb.checked;
|
|
810
|
+
syncCb.setAttribute('aria-checked', on ? 'true' : 'false');
|
|
811
|
+
try {
|
|
812
|
+
localStorage.setItem(STORAGE_SYNC, on ? '1' : '0');
|
|
813
|
+
} catch (e) {}
|
|
814
|
+
updateSyncBadge();
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
var hardwareChecked = false;
|
|
818
|
+
try {
|
|
819
|
+
hardwareChecked = localStorage.getItem(STORAGE_HARDWARE) === '1';
|
|
820
|
+
} catch (e) {}
|
|
821
|
+
var cb = document.getElementById('hardware-frames');
|
|
822
|
+
if (cb) {
|
|
823
|
+
cb.checked = hardwareChecked;
|
|
824
|
+
cb.setAttribute('aria-checked', hardwareChecked ? 'true' : 'false');
|
|
825
|
+
setHardwareMode(hardwareChecked);
|
|
826
|
+
cb.addEventListener('change', function() {
|
|
827
|
+
var on = cb.checked;
|
|
828
|
+
cb.setAttribute('aria-checked', on ? 'true' : 'false');
|
|
829
|
+
setHardwareMode(on);
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
var panes = document.querySelectorAll('.pane');
|
|
834
|
+
panes.forEach(function(pane, index) {
|
|
835
|
+
var select = pane.querySelector('.device-select');
|
|
836
|
+
buildSelect(select);
|
|
837
|
+
select.addEventListener('change', function() {
|
|
838
|
+
applyDeviceToPane(index, select.value);
|
|
839
|
+
saveStored();
|
|
840
|
+
});
|
|
841
|
+
var enlargeBtn = pane.querySelector('.btn-enlarge[data-enlarge]');
|
|
842
|
+
if (enlargeBtn) {
|
|
843
|
+
enlargeBtn.addEventListener('click', function() {
|
|
844
|
+
if (enlargedPaneIndex === index) {
|
|
845
|
+
setEnlargedPane(null);
|
|
846
|
+
} else {
|
|
847
|
+
setEnlargedPane(index);
|
|
848
|
+
}
|
|
849
|
+
});
|
|
850
|
+
}
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
document.querySelectorAll('.viewport').forEach(function(iframe) {
|
|
854
|
+
iframe.src = targetUrl;
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
loadStored();
|
|
858
|
+
document.getElementById('reset').addEventListener('click', reset);
|
|
859
|
+
setupSync();
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (document.readyState === 'loading') {
|
|
863
|
+
document.addEventListener('DOMContentLoaded', init);
|
|
864
|
+
} else {
|
|
865
|
+
init();
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
window.addEventListener('resize', function() {
|
|
869
|
+
for (var p = 0; p < PANE_COUNT; p++) {
|
|
870
|
+
var pane = document.querySelector('.pane[data-pane="' + p + '"]');
|
|
871
|
+
var select = pane ? pane.querySelector('.device-select') : null;
|
|
872
|
+
if (select && select.value) applyDeviceToPane(p, select.value);
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
})();
|
|
876
|
+
</script>
|
|
877
|
+
</body>
|
|
878
|
+
</html>
|