v1.0.0
This commit is contained in:
@@ -49,7 +49,6 @@ const FALLBACK_TOOLS: ToolDefinition[] = [
|
||||
command: 'hplToolbox.openPlayworksConverter',
|
||||
title: 'Playworks Converter',
|
||||
description: 'Convert Playworks HTML to per-network variants',
|
||||
beta: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -2,8 +2,26 @@ import * as vscode from 'vscode';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import { getToolWebviewStyles } from './shared';
|
||||
|
||||
const GS_DEVICES_KEY = 'hplDeviceSimulator.remoteDevices.v1';
|
||||
const GS_LANGUAGES_KEY = 'hplDeviceSimulator.remoteLanguages.v1';
|
||||
|
||||
function getSimulatorConfig() {
|
||||
const cfg = vscode.workspace.getConfiguration('hplToolbox.deviceSimulator');
|
||||
return {
|
||||
devicesUrl: (cfg.get<string>('devicesUrl') || '').trim(),
|
||||
languagesUrl: (cfg.get<string>('languagesUrl') || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveGDriveUrl(url: string): string {
|
||||
const fileId = url.match(/\/file\/d\/([a-zA-Z0-9_-]+)/)?.[1]
|
||||
?? url.match(/[?&]id=([a-zA-Z0-9_-]+)/)?.[1];
|
||||
return fileId ? `https://drive.google.com/uc?export=download&id=${fileId}` : url;
|
||||
}
|
||||
|
||||
const store: {
|
||||
panel: vscode.WebviewPanel | null;
|
||||
previewServer: http.Server | null;
|
||||
@@ -23,33 +41,49 @@ interface Device {
|
||||
}
|
||||
|
||||
const DEVICES: Device[] = [
|
||||
// ── iPhone 17 ──────────────────────────────────────────────────────────────
|
||||
{ id: 'iphone-17-pro-max', name: 'iPhone 17 Pro Max', sw: 440, sh: 956, scr: 44, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||
{ id: 'iphone-17-pro', name: 'iPhone 17 Pro', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||
{ id: 'iphone-air', name: 'iPhone Air', sw: 420, sh: 912, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||
{ id: 'iphone-17', name: 'iPhone 17', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||
// ── iPhone 16 ──────────────────────────────────────────────────────────────
|
||||
{ id: 'iphone-16-pro-max', name: 'iPhone 16 Pro Max', sw: 440, sh: 956, scr: 44, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||
{ id: 'iphone-16-pro', name: 'iPhone 16 Pro', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||
{ id: 'iphone-16-plus', name: 'iPhone 16 Plus', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
{ id: 'iphone-16', name: 'iPhone 16', sw: 390, sh: 844, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
// ── iPhone 15 ──────────────────────────────────────────────────────────────
|
||||
{ id: 'iphone-15-pro-max', name: 'iPhone 15 Pro Max', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
{ id: 'iphone-15-pro', name: 'iPhone 15 Pro', sw: 393, sh: 852, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
{ id: 'iphone-15-plus', name: 'iPhone 15 Plus', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
{ id: 'iphone-15', name: 'iPhone 15', sw: 390, sh: 844, scr: 38, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
// ── iPhone 14 ──────────────────────────────────────────────────────────────
|
||||
{ id: 'iphone-14-pro-max', name: 'iPhone 14 Pro Max', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
{ id: 'iphone-14-pro', name: 'iPhone 14 Pro', sw: 393, sh: 852, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
{ id: 'iphone-14-plus', name: 'iPhone 14 Plus', sw: 428, sh: 926, scr: 40, notch: 'notch', nw: 130, nh: 34, sat: 47, sab: 34 },
|
||||
{ id: 'iphone-14', name: 'iPhone 14', sw: 390, sh: 844, scr: 38, notch: 'notch', nw: 130, nh: 34, sat: 47, sab: 34 },
|
||||
// ── Other ──────────────────────────────────────────────────────────────────
|
||||
{ id: 'iphone-se3', name: 'iPhone SE (3rd gen)', sw: 375, sh: 667, scr: 4, notch: 'none', sat: 20, sab: 0 },
|
||||
{ id: 'pixel-8', name: 'Google Pixel 8', sw: 411, sh: 914, scr: 22, notch: 'punch', ps: 14, sat: 28, sab: 24 },
|
||||
{ id: 'galaxy-s24', name: 'Samsung Galaxy S24', sw: 360, sh: 780, scr: 20, notch: 'punch', ps: 12, sat: 28, sab: 24 },
|
||||
{ id: 'pixel-8', name: 'Google Pixel 8', sw: 411, sh: 914, scr: 22, notch: 'punch', ps: 14, sat: 28, sab: 24 },
|
||||
{ id: 'iphone-14', name: 'iPhone 14', sw: 390, sh: 844, scr: 38, notch: 'notch', nw: 130, nh: 34, sat: 47, sab: 34 },
|
||||
{ id: 'iphone-14-plus', name: 'iPhone 14 Plus', sw: 428, sh: 926, scr: 40, notch: 'notch', nw: 130, nh: 34, sat: 47, sab: 34 },
|
||||
{ id: 'iphone-14-pro', name: 'iPhone 14 Pro', sw: 393, sh: 852, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
{ id: 'iphone-14-pro-max', name: 'iPhone 14 Pro Max', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
{ id: 'iphone-15', name: 'iPhone 15', sw: 390, sh: 844, scr: 38, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
{ id: 'iphone-15-plus', name: 'iPhone 15 Plus', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
{ id: 'iphone-15-pro', name: 'iPhone 15 Pro', sw: 393, sh: 852, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
{ id: 'iphone-15-pro-max', name: 'iPhone 15 Pro Max', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
{ id: 'iphone-16', name: 'iPhone 16', sw: 390, sh: 844, scr: 40, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
{ id: 'iphone-16-plus', name: 'iPhone 16 Plus', sw: 430, sh: 932, scr: 42, notch: 'island', nw: 120, nh: 36, nr: 18, sat: 59, sab: 34 },
|
||||
{ id: 'iphone-16-pro', name: 'iPhone 16 Pro', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||
{ id: 'iphone-16-pro-max', name: 'iPhone 16 Pro Max', sw: 440, sh: 956, scr: 44, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||
{ id: 'iphone-17', name: 'iPhone 17', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||
{ id: 'iphone-17-pro', name: 'iPhone 17 Pro', sw: 402, sh: 874, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||
{ id: 'iphone-17-pro-max', name: 'iPhone 17 Pro Max', sw: 440, sh: 956, scr: 44, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||
{ id: 'iphone-air', name: 'iPhone Air', sw: 420, sh: 912, scr: 42, notch: 'island', nw: 126, nh: 37, nr: 20, sat: 62, sab: 34 },
|
||||
{ id: 'iphone-se3', name: 'iPhone SE (3rd gen)', sw: 375, sh: 667, scr: 4, notch: 'none', sat: 20, sab: 0 },
|
||||
{ id: 'galaxy-s24', name: 'Samsung Galaxy S24', sw: 360, sh: 780, scr: 20, notch: 'punch', ps: 12, sat: 28, sab: 24 },
|
||||
];
|
||||
|
||||
export function openDeviceSimulator(_context: vscode.ExtensionContext) {
|
||||
function fetchJson(url: string, redirectsLeft = 3): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = https.get(url, { headers: { 'User-Agent': 'HPLToolbox-Extension' } }, (res) => {
|
||||
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
||||
if (redirectsLeft > 0) {
|
||||
fetchJson(res.headers.location, redirectsLeft - 1).then(resolve).catch(reject);
|
||||
} else {
|
||||
reject(new Error('Too many redirects'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}`)); return; }
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => { try { resolve(JSON.parse(data)); } catch (e) { reject(e); } });
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(8000, () => { req.destroy(); reject(new Error('timeout')); });
|
||||
});
|
||||
}
|
||||
|
||||
export function openDeviceSimulator(context: vscode.ExtensionContext) {
|
||||
if (store.panel) {
|
||||
store.panel.reveal();
|
||||
return;
|
||||
@@ -75,6 +109,29 @@ export function openDeviceSimulator(_context: vscode.ExtensionContext) {
|
||||
});
|
||||
panel.webview.html = getHtml(DEVICES);
|
||||
|
||||
// Send cached remote data immediately so dropdowns update without waiting for network
|
||||
const cachedDevices = context.globalState.get<string>(GS_DEVICES_KEY);
|
||||
const cachedLanguages = context.globalState.get<string>(GS_LANGUAGES_KEY);
|
||||
if (cachedDevices) panel.webview.postMessage({ type: 'updateRemoteDevices', payload: cachedDevices });
|
||||
if (cachedLanguages) panel.webview.postMessage({ type: 'updateRemoteLanguages', payload: cachedLanguages });
|
||||
|
||||
// Background fetch — updates cache and pushes fresh data if panel is still open
|
||||
const simCfg = getSimulatorConfig();
|
||||
if (simCfg.devicesUrl) {
|
||||
fetchJson(resolveGDriveUrl(simCfg.devicesUrl)).then((data) => {
|
||||
const payload = JSON.stringify(data);
|
||||
context.globalState.update(GS_DEVICES_KEY, payload);
|
||||
panel.webview.postMessage({ type: 'updateRemoteDevices', payload });
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (simCfg.languagesUrl) {
|
||||
fetchJson(resolveGDriveUrl(simCfg.languagesUrl)).then((data) => {
|
||||
const payload = JSON.stringify(data);
|
||||
context.globalState.update(GS_LANGUAGES_KEY, payload);
|
||||
panel.webview.postMessage({ type: 'updateRemoteLanguages', payload });
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
let lastFilePath = '';
|
||||
|
||||
panel.webview.onDidReceiveMessage(async (msg) => {
|
||||
@@ -92,15 +149,8 @@ export function openDeviceSimulator(_context: vscode.ExtensionContext) {
|
||||
}
|
||||
} else if (msg.type === 'reload') {
|
||||
if (lastFilePath) sendFile(panel, lastFilePath, false);
|
||||
} else if (msg.type === 'exportDevices') {
|
||||
const target = await vscode.window.showSaveDialog({
|
||||
defaultUri: vscode.Uri.file('hpl-device-simulator-devices.json'),
|
||||
filters: { 'JSON': ['json'] },
|
||||
saveLabel: 'Export Devices',
|
||||
});
|
||||
if (target) {
|
||||
await fs.promises.writeFile(target.fsPath, String(msg.content || ''), 'utf8');
|
||||
}
|
||||
} else if (msg.type === 'openSettings') {
|
||||
vscode.commands.executeCommand('workbench.action.openSettings', 'hplToolbox.deviceSimulator');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -204,27 +254,18 @@ function getHtml(devices: Device[]): string {
|
||||
<style>
|
||||
${getToolWebviewStyles()}
|
||||
|
||||
/* ── layout ─────────────────────────────────────────────────────────── */
|
||||
html, body { min-height: 100%; }
|
||||
|
||||
/* Setup view — shown before any file is loaded */
|
||||
#setup-view { min-height: 100%; }
|
||||
|
||||
/* Simulator view — shown after first file load */
|
||||
#sim-view {
|
||||
display: none;
|
||||
height: min(720px, calc(100vh - 280px));
|
||||
min-height: 520px;
|
||||
margin-top: var(--tool-gap-lg);
|
||||
gap: var(--tool-gap-lg);
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
overflow: hidden;
|
||||
align-items: stretch;
|
||||
}
|
||||
#sim-view.active { display: grid; }
|
||||
|
||||
/* shared panel chrome */
|
||||
.sim-panel {
|
||||
border: 1px solid var(--tool-border);
|
||||
border-radius: var(--tool-radius);
|
||||
@@ -234,8 +275,6 @@ html, body { min-height: 100%; }
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── left: preview ───────────────────────────────────────────────────── */
|
||||
#preview-area {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
@@ -245,10 +284,7 @@ html, body { min-height: 100%; }
|
||||
padding: 20px;
|
||||
position: relative;
|
||||
}
|
||||
#screen-scaler {
|
||||
transform-origin: top center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
#screen-scaler { transform-origin: top center; flex: 0 0 auto; }
|
||||
#device-screen {
|
||||
--cutout-bg: #000;
|
||||
position: relative;
|
||||
@@ -260,176 +296,29 @@ html, body { min-height: 100%; }
|
||||
0 0 0 4px rgba(255,255,255,0.05),
|
||||
0 8px 32px rgba(0,0,0,0.5);
|
||||
}
|
||||
#device-screen iframe {
|
||||
display: block;
|
||||
border: none;
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
}
|
||||
.cutout {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
background: var(--cutout-bg);
|
||||
}
|
||||
.title-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.title-select {
|
||||
width: 180px;
|
||||
min-width: 120px;
|
||||
}
|
||||
.title-orient {
|
||||
min-height: 28px;
|
||||
}
|
||||
.orient-icon {
|
||||
display: block;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
.title-orient.landscape .orient-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
#device-screen iframe { display: block; border: none; position: absolute; top: 0; left: 0; }
|
||||
.cutout { position: absolute; pointer-events: none; z-index: 10; background: var(--cutout-bg); }
|
||||
.title-actions { display: flex; align-items: center; gap: 8px; flex: 0 0 auto; }
|
||||
.title-select { width: 180px; min-width: 120px; }
|
||||
.title-orient { min-height: 28px; }
|
||||
.orient-icon { display: block; transition: transform 120ms ease; }
|
||||
.title-orient.landscape .orient-icon { transform: rotate(90deg); }
|
||||
.icon-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
width: 28px; height: 28px; min-height: 28px; padding: 0;
|
||||
display: grid; place-items: center; font-size: 15px; line-height: 1;
|
||||
background: var(--vscode-button-secondaryBackground);
|
||||
color: var(--vscode-button-secondaryForeground);
|
||||
border-color: var(--tool-border);
|
||||
}
|
||||
.icon-btn:hover { background: var(--vscode-button-secondaryHoverBackground); }
|
||||
.icon-btn[aria-pressed="true"] { color: var(--vscode-testing-iconPassed, #73c991); }
|
||||
.device-settings-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.device-settings-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 18px;
|
||||
background: rgba(0,0,0,0.48);
|
||||
}
|
||||
.device-settings-modal.active { display: flex; }
|
||||
.device-settings-dialog {
|
||||
width: min(560px, 100%);
|
||||
border: 1px solid var(--tool-border);
|
||||
border-radius: var(--tool-radius);
|
||||
background: var(--vscode-editor-background);
|
||||
box-shadow: 0 16px 48px rgba(0,0,0,0.45);
|
||||
overflow: hidden;
|
||||
}
|
||||
.device-list-manager {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 32px;
|
||||
gap: 8px;
|
||||
}
|
||||
.device-list {
|
||||
min-height: 220px;
|
||||
max-height: 320px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--tool-border);
|
||||
border-radius: var(--tool-radius);
|
||||
background: var(--tool-panel-soft);
|
||||
}
|
||||
.device-row {
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 34px;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--tool-border);
|
||||
}
|
||||
.device-row:last-child { border-bottom: 0; }
|
||||
.device-row.active { background: rgba(0,127,212,0.16); }
|
||||
.device-row-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.device-row-size {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.device-list-controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.device-settings-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.device-settings-footer button {
|
||||
min-height: 30px;
|
||||
}
|
||||
.device-settings-status {
|
||||
margin-top: 10px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 12px;
|
||||
min-height: 18px;
|
||||
}
|
||||
.device-form {
|
||||
display: none;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--tool-border);
|
||||
}
|
||||
.device-form.active { display: block; }
|
||||
.device-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
.device-form-grid label {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
font-size: 11px;
|
||||
}
|
||||
.device-form-grid .full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.settings-close {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
#sim-view {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(420px, 60vh);
|
||||
overflow: visible;
|
||||
}
|
||||
.title-select {
|
||||
width: min(180px, 36vw);
|
||||
}
|
||||
#sim-view { height: auto; min-height: 0; grid-template-rows: minmax(420px, 60vh); overflow: visible; }
|
||||
.title-select { width: min(180px, 36vw); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ── setup view (no file loaded yet) ─────────────────────────────── -->
|
||||
<div id="setup-view">
|
||||
<main class="tool-page">
|
||||
<header class="tool-header">
|
||||
@@ -437,9 +326,9 @@ html, body { min-height: 100%; }
|
||||
<p class="tool-description">Preview HTML playables inside accurate mobile device frames with notch and Dynamic Island overlays.</p>
|
||||
</header>
|
||||
<section class="tool-panel input-panel">
|
||||
<div class="panel-header device-settings-header">
|
||||
<div class="panel-header" style="display:flex;align-items:center;justify-content:space-between;gap:8px;">
|
||||
<h3 class="panel-title">Input</h3>
|
||||
<button id="settings-btn" class="icon-btn settings-close" title="Device Settings" aria-label="Device Settings">⚙</button>
|
||||
<button id="settings-btn" class="icon-btn" title="Open Extension Settings" aria-label="Open Extension Settings">⚙</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="control-label">Playable HTML</div>
|
||||
@@ -450,62 +339,7 @@ html, body { min-height: 100%; }
|
||||
<div id="setup-status" class="status-panel"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div id="device-settings-modal" class="device-settings-modal" role="dialog" aria-modal="true" aria-labelledby="device-settings-title">
|
||||
<div class="device-settings-dialog">
|
||||
<div class="panel-header device-settings-header">
|
||||
<h3 id="device-settings-title" class="panel-title">Device Settings</h3>
|
||||
<button id="close-settings-btn" class="icon-btn settings-close" title="Close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div class="device-list-manager">
|
||||
<div id="device-list" class="device-list" role="listbox" aria-label="Devices"></div>
|
||||
<div class="device-list-controls">
|
||||
<button id="add-device-btn" class="icon-btn" title="Add Device" aria-label="Add Device">+</button>
|
||||
<button id="remove-device-btn" class="icon-btn" title="Delete Selected Devices" aria-label="Delete Selected Devices">-</button>
|
||||
<button id="move-device-up-btn" class="icon-btn" title="Move Selected Up" aria-label="Move Selected Up">↑</button>
|
||||
<button id="move-device-down-btn" class="icon-btn" title="Move Selected Down" aria-label="Move Selected Down">↓</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="device-settings-status" class="device-settings-status"></div>
|
||||
<div id="device-form" class="device-form">
|
||||
<div class="device-form-grid">
|
||||
<label class="full">Name<input id="device-name-input" type="text" /></label>
|
||||
<label>Width<input id="device-sw-input" type="text" inputmode="numeric" /></label>
|
||||
<label>Height<input id="device-sh-input" type="text" inputmode="numeric" /></label>
|
||||
<label>Corner Radius<input id="device-scr-input" type="text" inputmode="numeric" /></label>
|
||||
<label>Notch
|
||||
<select id="device-notch-input">
|
||||
<option value="none">none</option>
|
||||
<option value="notch">notch</option>
|
||||
<option value="island">island</option>
|
||||
<option value="punch">punch</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Notch Width<input id="device-nw-input" type="text" inputmode="numeric" /></label>
|
||||
<label>Notch Height<input id="device-nh-input" type="text" inputmode="numeric" /></label>
|
||||
<label>Notch Radius<input id="device-nr-input" type="text" inputmode="numeric" /></label>
|
||||
<label>Punch Size<input id="device-ps-input" type="text" inputmode="numeric" /></label>
|
||||
<label>Safe Top<input id="device-sat-input" type="text" inputmode="numeric" /></label>
|
||||
<label>Safe Bottom<input id="device-sab-input" type="text" inputmode="numeric" /></label>
|
||||
</div>
|
||||
<div class="action-row">
|
||||
<button id="save-device-btn">Save Device</button>
|
||||
<button id="cancel-device-btn" class="secondary">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-settings-footer">
|
||||
<button id="restore-devices-btn" class="secondary">Restore Defaults</button>
|
||||
<button id="import-devices-btn" class="secondary">Import Devices</button>
|
||||
<button id="export-devices-btn" class="secondary">Export Devices</button>
|
||||
</div>
|
||||
<input id="import-devices-input" type="file" accept="application/json,.json" style="display:none" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── simulator view (file loaded) ───────────────────────────── -->
|
||||
<div id="sim-view">
|
||||
|
||||
<!-- left: device preview -->
|
||||
<section class="sim-panel">
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title">Playable</h3>
|
||||
@@ -525,15 +359,13 @@ html, body { min-height: 100%; }
|
||||
<div id="screen-scaler"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<script>
|
||||
const vscode = acquireVsCodeApi();
|
||||
const DEFAULT_DEVICES = ${devicesJson};
|
||||
const DEVICES_STORAGE_KEY = 'hplDeviceSimulator.devices.v1';
|
||||
const LANGUAGES = [
|
||||
let DEFAULT_DEVICES = ${devicesJson};
|
||||
let LANGUAGES = [
|
||||
{ name: 'English', code: 'en' },
|
||||
{ name: 'Chinese (Simplified)', code: 'zh-hans' },
|
||||
{ name: 'Chinese (Traditional)', code: 'zh-hant' },
|
||||
@@ -546,8 +378,8 @@ const LANGUAGES = [
|
||||
{ name: 'Spanish', code: 'es' }
|
||||
];
|
||||
|
||||
let devices = loadDevices();
|
||||
let device = devices[0] || DEFAULT_DEVICES[0];
|
||||
let devices = DEFAULT_DEVICES.slice();
|
||||
let device = devices[0];
|
||||
let language = 'en';
|
||||
let landscape = false;
|
||||
let currentContent = null;
|
||||
@@ -560,13 +392,12 @@ const mediaVolumes = new WeakMap();
|
||||
let muteObserver = null;
|
||||
let muteDocument = null;
|
||||
|
||||
/* ── device selector ─────────────────────────────────────────────────── */
|
||||
const sel = document.getElementById('device-select');
|
||||
const languageSelect = document.getElementById('language-select');
|
||||
populateDeviceSelect(device.id);
|
||||
populateLanguageSelect();
|
||||
sel.addEventListener('change', function() {
|
||||
device = devices[parseInt(sel.value, 10)] || devices[0] || DEFAULT_DEVICES[0];
|
||||
device = devices[parseInt(sel.value, 10)] || devices[0];
|
||||
renderScreen();
|
||||
if (currentContent) loadContent(currentContent);
|
||||
});
|
||||
@@ -574,94 +405,9 @@ languageSelect.addEventListener('change', function() {
|
||||
language = languageSelect.value || LANGUAGES[0].code;
|
||||
if (currentContent) loadContent(currentContent);
|
||||
});
|
||||
document.getElementById('add-device-btn').addEventListener('click', addDevice);
|
||||
document.getElementById('remove-device-btn').addEventListener('click', removeCurrentDevice);
|
||||
document.getElementById('import-devices-btn').addEventListener('click', function() {
|
||||
document.getElementById('import-devices-input').click();
|
||||
document.getElementById('settings-btn').addEventListener('click', function() {
|
||||
vscode.postMessage({ type: 'openSettings' });
|
||||
});
|
||||
document.getElementById('export-devices-btn').addEventListener('click', exportDevices);
|
||||
document.getElementById('restore-devices-btn').addEventListener('click', restoreDefaultDevices);
|
||||
document.getElementById('import-devices-input').addEventListener('change', importDevices);
|
||||
document.getElementById('settings-btn').addEventListener('click', openDeviceSettings);
|
||||
document.getElementById('close-settings-btn').addEventListener('click', closeDeviceSettings);
|
||||
document.getElementById('save-device-btn').addEventListener('click', saveDeviceForm);
|
||||
document.getElementById('cancel-device-btn').addEventListener('click', hideDeviceForm);
|
||||
document.getElementById('move-device-up-btn').addEventListener('click', moveSelectedDevicesUp);
|
||||
document.getElementById('move-device-down-btn').addEventListener('click', moveSelectedDevicesDown);
|
||||
document.getElementById('device-settings-modal').addEventListener('click', function(event) {
|
||||
if (event.target === event.currentTarget) closeDeviceSettings();
|
||||
});
|
||||
|
||||
function openDeviceSettings() {
|
||||
document.getElementById('device-settings-modal').classList.add('active');
|
||||
renderDeviceList();
|
||||
setDeviceSettingsStatus('');
|
||||
}
|
||||
|
||||
function closeDeviceSettings() {
|
||||
document.getElementById('device-settings-modal').classList.remove('active');
|
||||
hideDeviceForm();
|
||||
}
|
||||
|
||||
function setDeviceSettingsStatus(message) {
|
||||
document.getElementById('device-settings-status').textContent = message || '';
|
||||
}
|
||||
|
||||
function showDeviceForm() {
|
||||
const source = device || DEFAULT_DEVICES[0];
|
||||
document.getElementById('device-name-input').value = source.name ? source.name + ' Copy' : 'Custom Device';
|
||||
document.getElementById('device-sw-input').value = String(source.sw || 390);
|
||||
document.getElementById('device-sh-input').value = String(source.sh || 844);
|
||||
document.getElementById('device-scr-input').value = String(source.scr || 40);
|
||||
document.getElementById('device-notch-input').value = source.notch || 'none';
|
||||
document.getElementById('device-nw-input').value = source.nw === undefined ? '' : String(source.nw);
|
||||
document.getElementById('device-nh-input').value = source.nh === undefined ? '' : String(source.nh);
|
||||
document.getElementById('device-nr-input').value = source.nr === undefined ? '' : String(source.nr);
|
||||
document.getElementById('device-ps-input').value = source.ps === undefined ? '' : String(source.ps);
|
||||
document.getElementById('device-sat-input').value = String(source.sat || 0);
|
||||
document.getElementById('device-sab-input').value = String(source.sab || 0);
|
||||
document.getElementById('device-form').classList.add('active');
|
||||
setDeviceSettingsStatus('');
|
||||
}
|
||||
|
||||
function hideDeviceForm() {
|
||||
document.getElementById('device-form').classList.remove('active');
|
||||
}
|
||||
|
||||
function formOptionalNumber(id) {
|
||||
const value = document.getElementById(id).value.trim();
|
||||
return value === '' ? undefined : Number(value);
|
||||
}
|
||||
|
||||
function saveDeviceForm() {
|
||||
const name = document.getElementById('device-name-input').value.trim();
|
||||
const created = normalizeDevice({
|
||||
id: (slugify(name) || 'custom-device') + '-' + Date.now(),
|
||||
name: name,
|
||||
sw: Number(document.getElementById('device-sw-input').value),
|
||||
sh: Number(document.getElementById('device-sh-input').value),
|
||||
scr: Number(document.getElementById('device-scr-input').value),
|
||||
notch: document.getElementById('device-notch-input').value,
|
||||
nw: formOptionalNumber('device-nw-input'),
|
||||
nh: formOptionalNumber('device-nh-input'),
|
||||
nr: formOptionalNumber('device-nr-input'),
|
||||
ps: formOptionalNumber('device-ps-input'),
|
||||
sat: Number(document.getElementById('device-sat-input').value),
|
||||
sab: Number(document.getElementById('device-sab-input').value)
|
||||
}, devices.length);
|
||||
if (!created) {
|
||||
setDeviceSettingsStatus('Enter at least a valid width and height.');
|
||||
return;
|
||||
}
|
||||
devices.push(created);
|
||||
saveDevices();
|
||||
populateDeviceSelect(created.id);
|
||||
renderDeviceList([created.id]);
|
||||
hideDeviceForm();
|
||||
setDeviceSettingsStatus('Added ' + created.name + '.');
|
||||
renderScreen();
|
||||
if (currentContent) loadContent(currentContent);
|
||||
}
|
||||
|
||||
function populateDeviceSelect(selectedId) {
|
||||
sel.innerHTML = '';
|
||||
@@ -671,11 +417,9 @@ function populateDeviceSelect(selectedId) {
|
||||
o.textContent = d.name;
|
||||
sel.appendChild(o);
|
||||
});
|
||||
const foundIndex = devices.findIndex(function(d) { return d.id === selectedId; });
|
||||
const index = foundIndex >= 0 ? foundIndex : 0;
|
||||
sel.value = String(index);
|
||||
device = devices[index] || DEFAULT_DEVICES[0];
|
||||
renderDeviceList(getCheckedDeviceIds());
|
||||
const idx = Math.max(0, devices.findIndex(function(d) { return d.id === selectedId; }));
|
||||
sel.value = String(idx);
|
||||
device = devices[idx] || devices[0];
|
||||
}
|
||||
|
||||
function populateLanguageSelect() {
|
||||
@@ -689,83 +433,6 @@ function populateLanguageSelect() {
|
||||
languageSelect.value = language;
|
||||
}
|
||||
|
||||
function renderDeviceList(checkedIds) {
|
||||
const list = document.getElementById('device-list');
|
||||
if (!list) return;
|
||||
const checked = new Set(checkedIds || []);
|
||||
list.innerHTML = '';
|
||||
devices.forEach(function(d, i) {
|
||||
const row = document.createElement('label');
|
||||
row.className = 'device-row' + (d.id === device.id ? ' active' : '');
|
||||
row.setAttribute('role', 'option');
|
||||
row.title = d.name;
|
||||
const input = document.createElement('input');
|
||||
input.type = 'checkbox';
|
||||
input.value = d.id;
|
||||
input.checked = checked.has(d.id);
|
||||
const name = document.createElement('span');
|
||||
name.className = 'device-row-name';
|
||||
name.textContent = d.name;
|
||||
const size = document.createElement('span');
|
||||
size.className = 'device-row-size';
|
||||
size.textContent = d.sw + ' x ' + d.sh;
|
||||
row.append(input, name, size);
|
||||
row.addEventListener('dblclick', function() {
|
||||
populateDeviceSelect(d.id);
|
||||
renderScreen();
|
||||
if (currentContent) loadContent(currentContent);
|
||||
});
|
||||
list.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function getCheckedDeviceIds() {
|
||||
return Array.from(document.querySelectorAll('#device-list input[type="checkbox"]:checked')).map(function(input) {
|
||||
return input.value;
|
||||
});
|
||||
}
|
||||
|
||||
function getCheckedDeviceIndexes() {
|
||||
const checked = new Set(getCheckedDeviceIds());
|
||||
return devices.map(function(d, i) { return checked.has(d.id) ? i : -1; }).filter(function(i) { return i >= 0; });
|
||||
}
|
||||
|
||||
function loadDevices() {
|
||||
try {
|
||||
const raw = localStorage.getItem(DEVICES_STORAGE_KEY);
|
||||
if (raw) {
|
||||
const parsed = normalizeDeviceList(JSON.parse(raw));
|
||||
if (parsed.length) return parsed;
|
||||
}
|
||||
} catch {}
|
||||
return DEFAULT_DEVICES.map(function(d) { return Object.assign({}, d); });
|
||||
}
|
||||
|
||||
function saveDevices() {
|
||||
localStorage.setItem(DEVICES_STORAGE_KEY, JSON.stringify(devices));
|
||||
}
|
||||
|
||||
function cloneDefaultDevices() {
|
||||
return DEFAULT_DEVICES.map(function(d) { return Object.assign({}, d); });
|
||||
}
|
||||
|
||||
function restoreDefaultDevices() {
|
||||
const existingIds = new Set(devices.map(function(d) { return d.id; }));
|
||||
const restored = cloneDefaultDevices().filter(function(d) { return !existingIds.has(d.id); });
|
||||
if (!restored.length) {
|
||||
setDeviceSettingsStatus('All default devices are already present.');
|
||||
return;
|
||||
}
|
||||
devices = devices.concat(restored);
|
||||
saveDevices();
|
||||
populateDeviceSelect(restored[0].id);
|
||||
hideDeviceForm();
|
||||
renderDeviceList(restored.map(function(d) { return d.id; }));
|
||||
setDeviceSettingsStatus('Restored ' + restored.length + ' default device(s).');
|
||||
renderScreen();
|
||||
if (currentContent) loadContent(currentContent);
|
||||
}
|
||||
|
||||
function normalizeDeviceList(value) {
|
||||
const list = Array.isArray(value) ? value : (value && Array.isArray(value.devices) ? value.devices : []);
|
||||
return list.map(normalizeDevice).filter(Boolean);
|
||||
@@ -803,103 +470,13 @@ function slugify(value) {
|
||||
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
function addDevice() {
|
||||
showDeviceForm();
|
||||
function sortDevicesByName(arr) {
|
||||
return arr.slice().sort(function(a, b) {
|
||||
const na = a.name.toLowerCase(), nb = b.name.toLowerCase();
|
||||
return na < nb ? -1 : na > nb ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function removeCurrentDevice() {
|
||||
const selectedIds = getCheckedDeviceIds();
|
||||
if (!selectedIds.length) {
|
||||
setDeviceSettingsStatus('Select one or more devices to delete.');
|
||||
return;
|
||||
}
|
||||
if (devices.length - selectedIds.length < 1) {
|
||||
setDeviceSettingsStatus('At least one device is required.');
|
||||
return;
|
||||
}
|
||||
const selected = new Set(selectedIds);
|
||||
const oldDeviceId = device.id;
|
||||
const removedCount = selectedIds.length;
|
||||
devices = devices.filter(function(d) { return !selected.has(d.id); });
|
||||
saveDevices();
|
||||
const nextDevice = devices.find(function(d) { return d.id === oldDeviceId; }) || devices[0];
|
||||
populateDeviceSelect(nextDevice.id);
|
||||
renderDeviceList([]);
|
||||
setDeviceSettingsStatus('Deleted ' + removedCount + ' device(s).');
|
||||
renderScreen();
|
||||
if (currentContent) loadContent(currentContent);
|
||||
}
|
||||
|
||||
function moveSelectedDevicesUp() {
|
||||
moveSelectedDevices(-1);
|
||||
}
|
||||
|
||||
function moveSelectedDevicesDown() {
|
||||
moveSelectedDevices(1);
|
||||
}
|
||||
|
||||
function moveSelectedDevices(direction) {
|
||||
const selectedIds = getCheckedDeviceIds();
|
||||
if (!selectedIds.length) {
|
||||
setDeviceSettingsStatus('Select one or more devices to move.');
|
||||
return;
|
||||
}
|
||||
const selected = new Set(selectedIds);
|
||||
if (direction < 0) {
|
||||
for (let i = 1; i < devices.length; i++) {
|
||||
if (selected.has(devices[i].id) && !selected.has(devices[i - 1].id)) {
|
||||
const tmp = devices[i - 1];
|
||||
devices[i - 1] = devices[i];
|
||||
devices[i] = tmp;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = devices.length - 2; i >= 0; i--) {
|
||||
if (selected.has(devices[i].id) && !selected.has(devices[i + 1].id)) {
|
||||
const tmp = devices[i + 1];
|
||||
devices[i + 1] = devices[i];
|
||||
devices[i] = tmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
saveDevices();
|
||||
populateDeviceSelect(device.id);
|
||||
renderDeviceList(selectedIds);
|
||||
setDeviceSettingsStatus('Moved ' + selectedIds.length + ' device(s).');
|
||||
}
|
||||
|
||||
function exportDevices() {
|
||||
const payload = JSON.stringify({ version: 1, devices: devices }, null, 2);
|
||||
vscode.postMessage({ type: 'exportDevices', content: payload });
|
||||
}
|
||||
|
||||
function importDevices(event) {
|
||||
const file = event.target.files && event.target.files[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = function() {
|
||||
try {
|
||||
const imported = normalizeDeviceList(JSON.parse(String(reader.result || '')));
|
||||
if (!imported.length) {
|
||||
setDeviceSettingsStatus('No valid devices found.');
|
||||
return;
|
||||
}
|
||||
devices = imported;
|
||||
saveDevices();
|
||||
populateDeviceSelect(devices[0].id);
|
||||
renderDeviceList([]);
|
||||
setDeviceSettingsStatus('Imported ' + imported.length + ' device(s).');
|
||||
renderScreen();
|
||||
if (currentContent) loadContent(currentContent);
|
||||
} catch (e) {
|
||||
setDeviceSettingsStatus('Could not import devices: ' + (e.message || e));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
|
||||
/* ── orientation ────────────────────────────────────────────────────── */
|
||||
document.getElementById('orient-btn').addEventListener('click', function() {
|
||||
landscape = !landscape;
|
||||
renderOrientationButton();
|
||||
@@ -940,11 +517,9 @@ document.getElementById('mute-btn').addEventListener('click', function() {
|
||||
applyMute();
|
||||
});
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────────────── */
|
||||
function sw() { return landscape ? device.sh : device.sw; }
|
||||
function sh() { return landscape ? device.sw : device.sh; }
|
||||
|
||||
/* ── cutout overlay HTML ─────────────────────────────────────────────── */
|
||||
function buildCutout() {
|
||||
const d = device;
|
||||
if (d.notch === 'notch') {
|
||||
@@ -979,13 +554,11 @@ function buildHomeBar() {
|
||||
return '<div style="' + base + 'right:8px;top:50%;transform:translateY(-50%);width:5px;height:100px;"></div>';
|
||||
}
|
||||
|
||||
/* ── render ──────────────────────────────────────────────────────────── */
|
||||
function renderScreen() {
|
||||
const W = sw(), H = sh();
|
||||
const scaler = document.getElementById('screen-scaler');
|
||||
scaler.innerHTML =
|
||||
'<div id="device-screen"' +
|
||||
' style="width:' + W + 'px;height:' + H + 'px;border-radius:' + device.scr + 'px;">' +
|
||||
'<div id="device-screen" style="width:' + W + 'px;height:' + H + 'px;border-radius:' + device.scr + 'px;">' +
|
||||
buildCutout() +
|
||||
'<iframe id="preview-frame" width="' + W + '" height="' + H +
|
||||
'" scrolling="no" frameborder="0" allow="autoplay;pointer-lock"></iframe>' +
|
||||
@@ -1000,20 +573,14 @@ function renderScreen() {
|
||||
function loadContent(html) {
|
||||
const frame = document.getElementById('preview-frame');
|
||||
if (!frame) return;
|
||||
|
||||
if (currentBlobUrl) {
|
||||
URL.revokeObjectURL(currentBlobUrl);
|
||||
currentBlobUrl = null;
|
||||
}
|
||||
|
||||
if (currentSourceUrl) {
|
||||
frame.src = withPreviewParams(currentSourceUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Revoke previous blob URL to free memory
|
||||
// Blob URL iframe: the blob document has its own security context — VS Code
|
||||
// does not inject its nonce-based CSP into it, so inline scripts run freely.
|
||||
const blob = new Blob([injectMuteBridge(html)], { type: 'text/html; charset=utf-8' });
|
||||
currentBlobUrl = URL.createObjectURL(blob);
|
||||
frame.src = currentBlobUrl;
|
||||
@@ -1121,6 +688,38 @@ function updateScale() {
|
||||
window.addEventListener('message', function(event) {
|
||||
const msg = event.data;
|
||||
if (!msg || !msg.type) return;
|
||||
|
||||
if (msg.type === 'updateRemoteDevices') {
|
||||
try {
|
||||
const newDefaults = normalizeDeviceList(JSON.parse(msg.payload));
|
||||
if (!newDefaults.length) return;
|
||||
DEFAULT_DEVICES = sortDevicesByName(newDefaults);
|
||||
devices = DEFAULT_DEVICES.slice();
|
||||
const currentId = device ? device.id : null;
|
||||
const stillExists = currentId && devices.some(function(d) { return d.id === currentId; });
|
||||
populateDeviceSelect(stillExists ? currentId : devices[0].id);
|
||||
renderScreen();
|
||||
if (currentContent) loadContent(currentContent);
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'updateRemoteLanguages') {
|
||||
try {
|
||||
const parsed = JSON.parse(msg.payload);
|
||||
const list = Array.isArray(parsed) ? parsed : (parsed && Array.isArray(parsed.languages) ? parsed.languages : []);
|
||||
const valid = list.filter(function(l) { return l && l.name && l.code; });
|
||||
if (!valid.length) return;
|
||||
LANGUAGES = valid;
|
||||
const prev = language;
|
||||
populateLanguageSelect();
|
||||
const stillValid = LANGUAGES.some(function(l) { return l.code === prev; });
|
||||
language = stillValid ? prev : LANGUAGES[0].code;
|
||||
languageSelect.value = language;
|
||||
} catch {}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'fileLoaded') {
|
||||
currentContent = msg.content;
|
||||
currentSourceUrl = msg.sourceUrl || '';
|
||||
|
||||
Reference in New Issue
Block a user