mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(ui): render the Display panel's Draw controls from the store
This commit is contained in:
1 parent
bca7495cbf
commit
b870df4199
9 files changed
+92
-42
No files matched your search
@@ -96,10 +96,11 @@ They contain no application state.
|
|||||||
descriptor before acting), Display (in progress: `DisplayPanel` renders
|
descriptor before acting), Display (in progress: `DisplayPanel` renders
|
||||||
the HUD, Sonar, Scope, Celestial, detection, bloom and sharpen from the
|
the HUD, Sonar, Scope, Celestial, detection, bloom and sharpen from the
|
||||||
`gev-display` store, which VisualSettings publishes to, and the 3D
|
`gev-display` store, which VisualSettings publishes to, and the 3D
|
||||||
aircraft group, which AircraftDisplay publishes beside it; the style
|
aircraft group and the Draw controls, which AircraftDisplay and the draw
|
||||||
parameter rows render through Vue behind `createStyleParameters()`, as a
|
tool publish beside it; the style parameter rows render through Vue
|
||||||
render function so the portable `./ui/input` entry needs no component
|
behind `createStyleParameters()`, as a render function so the portable
|
||||||
compiler; the other groups follow one owner at a time),
|
`./ui/input` entry needs no component compiler; the other groups follow
|
||||||
|
one owner at a time),
|
||||||
Context and Radio, the command dock,
|
Context and Radio, the command dock,
|
||||||
the phone shell, then Cockpit. Layers that write into the right rail
|
the phone shell, then Cockpit. Layers that write into the right rail
|
||||||
directly gain a slot instead.
|
directly gain a slot instead.
|
||||||
|
|||||||
+13
-16
@@ -67,17 +67,17 @@ const PREVIEW = {
|
|||||||
/**
|
/**
|
||||||
* Wire the Draw control. Returns a handle with a `destroy()` the application
|
* Wire the Draw control. Returns a handle with a `destroy()` the application
|
||||||
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
|
* lifetime owns, plus the console/test seam (`window.__gevDrawTool`).
|
||||||
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}}} deps
|
* The tool keeps its listeners on the controls and hands what they show
|
||||||
|
* (`{ active, shape, hint }`) to `publish`, which the view renders.
|
||||||
|
* @param {{viewer: Cesium.Viewer, annotations: {annotate: Function, clear: Function}, publish?: Function}} deps
|
||||||
* @returns {{destroy: Function}|null}
|
* @returns {{destroy: Function}|null}
|
||||||
*/
|
*/
|
||||||
export function initDrawTool({ viewer, annotations }) {
|
export function initDrawTool({ viewer, annotations, publish = () => {} }) {
|
||||||
const toggle = document.getElementById('draw-toggle');
|
const toggle = document.getElementById('draw-toggle');
|
||||||
const modeRow = document.getElementById('draw-mode-row');
|
const modeRow = document.getElementById('draw-mode-row');
|
||||||
const labelRow = document.getElementById('draw-label-row');
|
|
||||||
const labelInput = document.getElementById('draw-label-input');
|
const labelInput = document.getElementById('draw-label-input');
|
||||||
const colorSelect = document.getElementById('draw-color-select');
|
const colorSelect = document.getElementById('draw-color-select');
|
||||||
const clearButton = document.getElementById('draw-clear');
|
const clearButton = document.getElementById('draw-clear');
|
||||||
const hint = document.getElementById('draw-hint');
|
|
||||||
if (!viewer || !annotations || !toggle) return null;
|
if (!viewer || !annotations || !toggle) return null;
|
||||||
|
|
||||||
let active = false;
|
let active = false;
|
||||||
@@ -85,6 +85,7 @@ export function initDrawTool({ viewer, annotations }) {
|
|||||||
let session = null;
|
let session = null;
|
||||||
let shape = 'area';
|
let shape = 'area';
|
||||||
let color = 'primary';
|
let color = 'primary';
|
||||||
|
let hint = '';
|
||||||
let handler = null;
|
let handler = null;
|
||||||
let lease = null;
|
let lease = null;
|
||||||
let savedDoubleClick = null;
|
let savedDoubleClick = null;
|
||||||
@@ -124,8 +125,10 @@ export function initDrawTool({ viewer, annotations }) {
|
|||||||
() => null,
|
() => null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const present = () => publish({ active, shape, hint });
|
||||||
const setHint = (text) => {
|
const setHint = (text) => {
|
||||||
if (hint) hint.textContent = text;
|
hint = text;
|
||||||
|
present();
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- preview ---------------------------------------------------------
|
// ---- preview ---------------------------------------------------------
|
||||||
@@ -354,10 +357,7 @@ export function initDrawTool({ viewer, annotations }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
active = next;
|
active = next;
|
||||||
toggle.classList.toggle('active', active);
|
present();
|
||||||
toggle.setAttribute('aria-pressed', String(active));
|
|
||||||
modeRow?.classList.toggle('visible', active);
|
|
||||||
labelRow?.classList.toggle('visible', active);
|
|
||||||
document.body.classList.toggle('gev-drawing', active);
|
document.body.classList.toggle('gev-drawing', active);
|
||||||
if (active) {
|
if (active) {
|
||||||
session = createDrawSession(shape);
|
session = createDrawSession(shape);
|
||||||
@@ -433,13 +433,13 @@ export function initDrawTool({ viewer, annotations }) {
|
|||||||
const shapeButtons = [
|
const shapeButtons = [
|
||||||
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
|
...(modeRow?.querySelectorAll('.pp-mode-btn[data-shape]') || []),
|
||||||
];
|
];
|
||||||
const shapeControl = createSegmentedControl({
|
createSegmentedControl({
|
||||||
buttons: shapeButtons,
|
buttons: shapeButtons,
|
||||||
valueOf: (button) => button.dataset.shape,
|
valueOf: (button) => button.dataset.shape,
|
||||||
listen,
|
listen,
|
||||||
onChange: (value) => {
|
onChange: (value) => {
|
||||||
shape = normalizeShape(value);
|
shape = normalizeShape(value);
|
||||||
shapeControl.update(shape);
|
present();
|
||||||
if (active) {
|
if (active) {
|
||||||
generation += 1;
|
generation += 1;
|
||||||
session = createDrawSession(shape);
|
session = createDrawSession(shape);
|
||||||
@@ -546,11 +546,8 @@ export function initDrawTool({ viewer, annotations }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.body.classList.remove('gev-drawing');
|
document.body.classList.remove('gev-drawing');
|
||||||
toggle.classList.remove('active');
|
hint = '';
|
||||||
toggle.setAttribute('aria-pressed', 'false');
|
present();
|
||||||
modeRow?.classList.remove('visible');
|
|
||||||
labelRow?.classList.remove('visible');
|
|
||||||
if (hint) hint.textContent = '';
|
|
||||||
if (window.__gevDrawTool === api) delete window.__gevDrawTool;
|
if (window.__gevDrawTool === api) delete window.__gevDrawTool;
|
||||||
return attaching;
|
return attaching;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ test('the application composition wires the draw tool and owns its teardown', ()
|
|||||||
const tools = read('src/app/tools.js');
|
const tools = read('src/app/tools.js');
|
||||||
const engineAt = tools.indexOf('const annotations = initAnnotations({');
|
const engineAt = tools.indexOf('const annotations = initAnnotations({');
|
||||||
const drawAt = tools.indexOf(
|
const drawAt = tools.indexOf(
|
||||||
'const drawTool = initDrawTool({ viewer, annotations });',
|
'const drawTool = initDrawTool({\n viewer,\n annotations,\n publish: (view) => displayStore.publishDraw(view),',
|
||||||
);
|
);
|
||||||
assert.ok(engineAt >= 0, 'initAnnotations call missing');
|
assert.ok(engineAt >= 0, 'initAnnotations call missing');
|
||||||
assert.ok(
|
assert.ok(
|
||||||
@@ -88,7 +88,9 @@ test('the Draw control markup carries the ids the tool binds to', () => {
|
|||||||
`shape button ${shape} missing`,
|
`shape button ${shape} missing`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const id of ids) {
|
// The rows and the hint render what the tool publishes; it binds the rest.
|
||||||
|
const bound = ids.filter((id) => id !== 'draw-label-row' && id !== 'draw-hint');
|
||||||
|
for (const id of bound) {
|
||||||
assert.match(
|
assert.match(
|
||||||
tool(),
|
tool(),
|
||||||
new RegExp(`getElementById\\('${id}'\\)`),
|
new RegExp(`getElementById\\('${id}'\\)`),
|
||||||
|
|||||||
@@ -218,9 +218,15 @@ function harness() {
|
|||||||
const previousWindow = globalThis.window;
|
const previousWindow = globalThis.window;
|
||||||
globalThis.document = documentDouble;
|
globalThis.document = documentDouble;
|
||||||
globalThis.window = globalThis.window || {};
|
globalThis.window = globalThis.window || {};
|
||||||
const tool = initDrawTool({ viewer, annotations });
|
const shown = { view: null };
|
||||||
|
const tool = initDrawTool({
|
||||||
|
viewer,
|
||||||
|
annotations,
|
||||||
|
publish: (view) => (shown.view = view),
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
tool,
|
tool,
|
||||||
|
shown,
|
||||||
viewer,
|
viewer,
|
||||||
annotations,
|
annotations,
|
||||||
byId,
|
byId,
|
||||||
@@ -289,7 +295,7 @@ test('Draw refuses to start when something else holds the pointer', async () =>
|
|||||||
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
|
assert.equal(pointerOwner(), 'directions', 'and must not steal the claim');
|
||||||
// Named by its tool, not by the internal owner id.
|
// Named by its tool, not by the internal owner id.
|
||||||
assert.equal(
|
assert.equal(
|
||||||
h.element('draw-hint').textContent,
|
h.shown.view.hint,
|
||||||
'Directions is using the pointer — close it first.',
|
'Directions is using the pointer — close it first.',
|
||||||
);
|
);
|
||||||
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
|
assert.equal(h.tool.diagnostics().sceneHandler, false, 'no handler was bound');
|
||||||
|
|||||||
+8
-1
@@ -2,6 +2,8 @@ import { SceneDirector } from '../scenes/director.js';
|
|||||||
import { initAnnotations } from '../annotations/index.js';
|
import { initAnnotations } from '../annotations/index.js';
|
||||||
import { initDrawTool } from '../annotations/drawTool.js';
|
import { initDrawTool } from '../annotations/drawTool.js';
|
||||||
import { initGeoFileImport } from '../ui/geoFileImport.js';
|
import { initGeoFileImport } from '../ui/geoFileImport.js';
|
||||||
|
import { useDisplayStore } from '../ui/stores/display.js';
|
||||||
|
import { applicationPinia } from '../ui/vueRuntime.js';
|
||||||
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
|
||||||
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
import { createRecentImageryPanel } from '../ui/recentImagery.js';
|
||||||
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
|
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
|
||||||
@@ -56,7 +58,12 @@ export function createApplicationTools({
|
|||||||
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
|
// DISPLAY ▸ Draw: the same whiteboard, drawn by hand. It claims the pointer
|
||||||
// while a session is open, so its teardown belongs to the application
|
// while a session is open, so its teardown belongs to the application
|
||||||
// lifetime rather than to whoever last pressed the button.
|
// lifetime rather than to whoever last pressed the button.
|
||||||
const drawTool = initDrawTool({ viewer, annotations });
|
const displayStore = useDisplayStore(applicationPinia());
|
||||||
|
const drawTool = initDrawTool({
|
||||||
|
viewer,
|
||||||
|
annotations,
|
||||||
|
publish: (view) => displayStore.publishDraw(view),
|
||||||
|
});
|
||||||
defer(() => drawTool?.destroy());
|
defer(() => drawTool?.destroy());
|
||||||
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
|
// DISPLAY ▸ Import: the person's own GeoJSON/KML/KMZ/GPX files, each its
|
||||||
// own data source. They live for the page session, so the application
|
// own data source. They live for the page session, so the application
|
||||||
|
|||||||
@@ -343,3 +343,30 @@ test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
|
|||||||
assert.equal(row.classList.contains('visible'), true);
|
assert.equal(row.classList.contains('visible'), true);
|
||||||
assert.equal(all.getAttribute('aria-checked'), 'false');
|
assert.equal(all.getAttribute('aria-checked'), 'false');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the Draw controls render what the draw tool publishes', async (t) => {
|
||||||
|
const { store, $ } = fixture(t);
|
||||||
|
const toggle = $('draw-toggle');
|
||||||
|
const segment = (shape) =>
|
||||||
|
document.querySelector(`#draw-mode-row [data-shape="${shape}"]`);
|
||||||
|
assert.equal(toggle.getAttribute('aria-pressed'), 'false');
|
||||||
|
assert.equal(segment('area').getAttribute('aria-checked'), 'true');
|
||||||
|
assert.equal($('draw-hint').textContent, '');
|
||||||
|
|
||||||
|
store.publishDraw({ active: true, shape: 'line', hint: 'Click to add points.' });
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(toggle.className, 'pp-toggle-btn active');
|
||||||
|
assert.equal(toggle.getAttribute('aria-pressed'), 'true');
|
||||||
|
assert.equal($('draw-mode-row').classList.contains('visible'), true);
|
||||||
|
assert.equal($('draw-label-row').classList.contains('visible'), true);
|
||||||
|
assert.equal(segment('line').getAttribute('aria-checked'), 'true');
|
||||||
|
assert.equal(segment('line').classList.contains('active'), true);
|
||||||
|
assert.equal(segment('area').classList.contains('active'), false);
|
||||||
|
assert.equal($('draw-hint').textContent, 'Click to add points.');
|
||||||
|
|
||||||
|
store.publishDraw({ active: false, shape: 'line', hint: '' });
|
||||||
|
await nextTick();
|
||||||
|
assert.equal(toggle.className, 'pp-toggle-btn');
|
||||||
|
assert.equal($('draw-mode-row').classList.contains('visible'), false);
|
||||||
|
assert.equal($('draw-label-row').classList.contains('visible'), false);
|
||||||
|
});
|
||||||
@@ -427,9 +427,7 @@ test('the application composition wires the import and owns its teardown', () =>
|
|||||||
new URL('../app/tools.js', import.meta.url),
|
new URL('../app/tools.js', import.meta.url),
|
||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
const drawAt = tools.indexOf(
|
const drawAt = tools.indexOf('const drawTool = initDrawTool({');
|
||||||
'const drawTool = initDrawTool({ viewer, annotations });',
|
|
||||||
);
|
|
||||||
const importAt = tools.indexOf('const geoFileImport = initGeoFileImport({');
|
const importAt = tools.indexOf('const geoFileImport = initGeoFileImport({');
|
||||||
assert.ok(importAt > drawAt && drawAt >= 0);
|
assert.ok(importAt > drawAt && drawAt >= 0);
|
||||||
// Fly-to goes through the application shell's navigation policy.
|
// Fly-to goes through the application shell's navigation policy.
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { markRaw, shallowRef } from 'vue';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The Display panel as its view renders it. VisualSettings presents its
|
* The Display panel as its view renders it. VisualSettings presents its
|
||||||
* groups (`_presentDisplay()`) and AircraftDisplay the 3D aircraft group,
|
* groups (`_presentDisplay()`), AircraftDisplay the 3D aircraft group and
|
||||||
* and each publishes here; both keep their listeners on the controls.
|
* the draw tool its controls, and each publishes here; each keeps its
|
||||||
|
* listeners on the controls.
|
||||||
*/
|
*/
|
||||||
export const useDisplayStore = defineStore('gev-display', () => {
|
export const useDisplayStore = defineStore('gev-display', () => {
|
||||||
/** The presented panel; null until VisualSettings first renders. */
|
/** The presented panel; null until VisualSettings first renders. */
|
||||||
@@ -23,5 +24,13 @@ export const useDisplayStore = defineStore('gev-display', () => {
|
|||||||
models3d.value = markRaw(next);
|
models3d.value = markRaw(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { view, publish, models3d, publishModels3d };
|
/** The Draw controls; null until the draw tool first renders. */
|
||||||
|
const draw = shallowRef(null);
|
||||||
|
|
||||||
|
/** @param {object} next A fresh presentation of the Draw controls. */
|
||||||
|
function publishDraw(next) {
|
||||||
|
draw.value = markRaw(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { view, publish, models3d, publishModels3d, draw, publishDraw };
|
||||||
});
|
});
|
||||||
@@ -6,10 +6,11 @@ import { vRangeValue } from '../../rail/rangeValue.js';
|
|||||||
import { useDisplayStore } from '../../stores/display.js';
|
import { useDisplayStore } from '../../stores/display.js';
|
||||||
|
|
||||||
// The Display panel. VisualSettings presents the HUD, Sonar, Scope,
|
// The Display panel. VisualSettings presents the HUD, Sonar, Scope,
|
||||||
// Celestial, detection, bloom and sharpen and AircraftDisplay the 3D group,
|
// Celestial, detection, bloom and sharpen, AircraftDisplay the 3D group and
|
||||||
// and each publishes them; until they do, the panel shows its initial
|
// the draw tool its controls, and each publishes them; until they do, the
|
||||||
// markup. The other groups keep their owners' element bindings.
|
// panel shows its initial markup. The other groups keep their owners'
|
||||||
const { view, models3d } = storeToRefs(useDisplayStore());
|
// element bindings.
|
||||||
|
const { view, models3d, draw } = storeToRefs(useDisplayStore());
|
||||||
const pct = (value) => `${value}%`;
|
const pct = (value) => `${value}%`;
|
||||||
const hud = computed(() => view.value?.hud ?? null);
|
const hud = computed(() => view.value?.hud ?? null);
|
||||||
const sonar = computed(() => view.value?.sonar ?? null);
|
const sonar = computed(() => view.value?.sonar ?? null);
|
||||||
@@ -33,6 +34,8 @@ const segment = (current) => (value) => ({
|
|||||||
'aria-checked': String(current.value === value),
|
'aria-checked': String(current.value === value),
|
||||||
});
|
});
|
||||||
const modelsSegment = segment(modelsMode);
|
const modelsSegment = segment(modelsMode);
|
||||||
|
const drawShape = computed(() => draw.value?.shape ?? 'area');
|
||||||
|
const drawSegment = segment(drawShape);
|
||||||
const allocationSegment = segment(allocation);
|
const allocationSegment = segment(allocation);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -156,19 +159,19 @@ const allocationSegment = segment(allocation);
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pp-toggle-group">
|
<div class="pp-toggle-group">
|
||||||
<button class="pp-toggle-btn" id="draw-toggle" title="Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel" aria-pressed="false" data-i18n-title>
|
<button class="pp-toggle-btn" :class="{ active: draw?.active }" id="draw-toggle" title="Draw on the world — click vertices, double-click or Enter to finish, Esc to cancel" :aria-pressed="String(draw?.active ?? false)" data-i18n-title>
|
||||||
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
|
<span class="pp-icon material-symbols-outlined" aria-hidden="true">draw</span>
|
||||||
<span class="pp-label" data-i18n>Draw</span>
|
<span class="pp-label" data-i18n>Draw</span>
|
||||||
</button>
|
</button>
|
||||||
<div class="pp-slider-row" id="draw-mode-row">
|
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-mode-row">
|
||||||
<span class="pp-slider-mini-label" data-i18n>Shape</span>
|
<span class="pp-slider-mini-label" data-i18n>Shape</span>
|
||||||
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
|
<div class="gev-segmented pp-mode-seg" role="radiogroup" aria-label="Shape to draw" data-i18n-aria-label>
|
||||||
<button class="gev-segment pp-mode-btn active" data-shape="area" role="radio" aria-checked="true" data-i18n>Area</button>
|
<button class="gev-segment pp-mode-btn" data-shape="area" role="radio" v-bind="drawSegment('area')" data-i18n>Area</button>
|
||||||
<button class="gev-segment pp-mode-btn" data-shape="line" role="radio" aria-checked="false" data-i18n>Line</button>
|
<button class="gev-segment pp-mode-btn" data-shape="line" role="radio" v-bind="drawSegment('line')" data-i18n>Line</button>
|
||||||
<button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" aria-checked="false" data-i18n>Pin</button>
|
<button class="gev-segment pp-mode-btn" data-shape="pin" role="radio" v-bind="drawSegment('pin')" data-i18n>Pin</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pp-slider-row" id="draw-label-row">
|
<div class="pp-slider-row" :class="{ visible: draw?.active }" id="draw-label-row">
|
||||||
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false">
|
<input type="text" class="pp-text-input" id="draw-label-input" maxlength="120" placeholder="Label (optional)" aria-label="Label for the drawn shape" data-i18n-placeholder data-i18n-aria-label autocomplete="off" spellcheck="false">
|
||||||
<select class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label>
|
<select class="pp-select draw-color-select gev-field gev-field--compact" id="draw-color-select" aria-label="Colour of the drawn shape" data-i18n-aria-label>
|
||||||
<option value="primary" data-i18n>Primary</option>
|
<option value="primary" data-i18n>Primary</option>
|
||||||
@@ -178,7 +181,7 @@ const allocationSegment = segment(allocation);
|
|||||||
<option value="red" data-i18n>Red</option>
|
<option value="red" data-i18n>Red</option>
|
||||||
</select>
|
</select>
|
||||||
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" title="Remove every mark from the board" data-i18n-title data-i18n>Clear</button>
|
<button class="pp-mode-btn draw-clear-btn" id="draw-clear" type="button" title="Remove every mark from the board" data-i18n-title data-i18n>Clear</button>
|
||||||
<span class="draw-hint" id="draw-hint" aria-live="polite"></span>
|
<span class="draw-hint" id="draw-hint" aria-live="polite">{{ draw?.hint ?? '' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pp-toggle-group" id="geo-import-group">
|
<div class="pp-toggle-group" id="geo-import-group">
|
||||||
|
|||||||
Reference in new issue
Block a user