mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-15 13:19:23 +00:00
Merge pull request #8054 from keymanapp/change/web/device-detector-modularization
change(web): device-detector subpackage modularization 🧩
This commit is contained in:
commit
f4ea4acfff
7 changed files with 209 additions and 223 deletions
|
|
@ -7,8 +7,6 @@
|
|||
# Exit on command failure and when using unset variables:
|
||||
set -eu
|
||||
|
||||
# Include some helper functions from resources
|
||||
|
||||
## START STANDARD BUILD SCRIPT INCLUDE
|
||||
# adjust relative paths as necessary
|
||||
THIS_SCRIPT="$(greadlink -f "${BASH_SOURCE[0]}" 2>/dev/null || readlink -f "${BASH_SOURCE[0]}")"
|
||||
|
|
|
|||
2
package-lock.json
generated
2
package-lock.json
generated
|
|
@ -7934,7 +7934,7 @@
|
|||
"@keymanapp/web-utils": "*",
|
||||
"@types/node": "^10.17.21",
|
||||
"chai": "^4.3.4",
|
||||
"combine-source-map": "*",
|
||||
"combine-source-map": "^0.8.0",
|
||||
"es6-shim": "^0.35.5",
|
||||
"karma": "^6.4.1",
|
||||
"karma-browserstack-launcher": "^1.6.0",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,10 @@ builder_parse "$@"
|
|||
|
||||
####
|
||||
|
||||
TIER=`cat ../TIER.md`
|
||||
BUILD_NUMBER=`cat ../VERSION.md`
|
||||
S_KEYMAN_COM=../../s.keyman.com
|
||||
|
||||
if builder_start_action build; then
|
||||
# Build step: since CI builds start (and should start) from scratch, run the following
|
||||
# three actions:
|
||||
|
|
|
|||
|
|
@ -1,203 +1,201 @@
|
|||
// Includes version-related functionality
|
||||
///<reference path="utils/styleConstants.ts" />
|
||||
import StyleConstants from 'utils/styleConstants.js';
|
||||
import { DeviceSpec, Version } from "@keymanapp/web-utils/build/obj/index.js";
|
||||
|
||||
// The Device object definition -------------------------------------------------
|
||||
|
||||
namespace com.keyman {
|
||||
export class Device {
|
||||
touchable: boolean;
|
||||
OS: string;
|
||||
formFactor: string;
|
||||
dyPortrait: number;
|
||||
dyLandscape: number;
|
||||
version: string;
|
||||
orientation: string|number;
|
||||
browser: string;
|
||||
colorScheme: 'light' | 'dark';
|
||||
export class Device {
|
||||
touchable: boolean;
|
||||
OS: string;
|
||||
formFactor: string;
|
||||
dyPortrait: number;
|
||||
dyLandscape: number;
|
||||
version: string;
|
||||
orientation: string|number;
|
||||
browser: string;
|
||||
colorScheme: 'light' | 'dark';
|
||||
|
||||
private detected: boolean = false;
|
||||
private _styles: utils.StyleConstants;
|
||||
private detected: boolean = false;
|
||||
private _styles: StyleConstants;
|
||||
|
||||
// Generates a default Device value.
|
||||
constructor() {
|
||||
this.touchable = !!('ontouchstart' in window);
|
||||
this.OS = '';
|
||||
this.formFactor='desktop';
|
||||
this.dyPortrait=0;
|
||||
this.dyLandscape=0;
|
||||
this.version='0';
|
||||
this.orientation=window.orientation;
|
||||
this.browser='';
|
||||
}
|
||||
// Generates a default Device value.
|
||||
constructor() {
|
||||
this.touchable = !!('ontouchstart' in window);
|
||||
this.OS = '';
|
||||
this.formFactor='desktop';
|
||||
this.dyPortrait=0;
|
||||
this.dyLandscape=0;
|
||||
this.version='0';
|
||||
this.orientation=window.orientation;
|
||||
this.browser='';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get device horizontal DPI for touch devices, to set actual size of active regions
|
||||
* Note that the actual physical DPI may be somewhat different.
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
getDPI(): number {
|
||||
var t=document.createElement('DIV') ,s=t.style,dpi=96;
|
||||
if(document.readyState !== 'complete') {
|
||||
return dpi;
|
||||
}
|
||||
|
||||
t.id='calculateDPI';
|
||||
s.position='absolute'; s.display='block';s.visibility='hidden';
|
||||
s.left='10px'; s.top='10px'; s.width='1in'; s.height='10px';
|
||||
document.body.appendChild(t);
|
||||
dpi=(typeof window.devicePixelRatio == 'undefined') ? t.offsetWidth : t.offsetWidth * window.devicePixelRatio;
|
||||
document.body.removeChild(t);
|
||||
/**
|
||||
* Get device horizontal DPI for touch devices, to set actual size of active regions
|
||||
* Note that the actual physical DPI may be somewhat different.
|
||||
*
|
||||
* @return {number}
|
||||
*/
|
||||
getDPI(): number {
|
||||
var t=document.createElement('DIV') ,s=t.style,dpi=96;
|
||||
if(document.readyState !== 'complete') {
|
||||
return dpi;
|
||||
}
|
||||
|
||||
detect() : void {
|
||||
var possMacSpoof = false;
|
||||
t.id='calculateDPI';
|
||||
s.position='absolute'; s.display='block';s.visibility='hidden';
|
||||
s.left='10px'; s.top='10px'; s.width='1in'; s.height='10px';
|
||||
document.body.appendChild(t);
|
||||
dpi=(typeof window.devicePixelRatio == 'undefined') ? t.offsetWidth : t.offsetWidth * window.devicePixelRatio;
|
||||
document.body.removeChild(t);
|
||||
return dpi;
|
||||
}
|
||||
|
||||
if(navigator && navigator.userAgent) {
|
||||
var agent=navigator.userAgent;
|
||||
detect() : void {
|
||||
var possMacSpoof = false;
|
||||
|
||||
if(agent.indexOf('iPad') >= 0) {
|
||||
this.OS='iOS';
|
||||
this.formFactor='tablet';
|
||||
this.dyPortrait=this.dyLandscape=0;
|
||||
} else if(agent.indexOf('iPhone') >= 0) {
|
||||
this.OS='iOS';
|
||||
this.formFactor='phone';
|
||||
this.dyPortrait=this.dyLandscape=25;
|
||||
} else if(agent.indexOf('Android') >= 0) {
|
||||
this.OS='Android';
|
||||
this.formFactor='phone'; // form factor may be redefined on initialization
|
||||
this.dyPortrait=75;
|
||||
this.dyLandscape=25;
|
||||
try {
|
||||
var rx=new RegExp("(?:Android\\s+)(\\d+\\.\\d+\\.\\d+)");
|
||||
this.version=agent.match(rx)[1];
|
||||
} catch(ex) {}
|
||||
} else if(agent.indexOf('Linux') >= 0) {
|
||||
this.OS='Linux';
|
||||
} else if(agent.indexOf('Macintosh') >= 0) {
|
||||
// Starting with 13.1, "Macintosh" can reflect iPads (by default) or iPhones
|
||||
// (by user setting); a new "Request Desktop Website" setting for Safari will
|
||||
// change the user agent string to match a desktop Mac.
|
||||
//
|
||||
// Firefox uses '.' between version components, while Chrome and Safari use
|
||||
// '_' instead. So, we have to check for both. Yay.
|
||||
let regex = /Intel Mac OS X (\d+(?:[_\.]\d+)+)/i;
|
||||
let results = regex.exec(agent);
|
||||
if(navigator && navigator.userAgent) {
|
||||
var agent=navigator.userAgent;
|
||||
|
||||
// Match result: a version string with components separated by underscores.
|
||||
if(!results) {
|
||||
console.warn("KMW could not properly parse the user agent string."
|
||||
+ "A suboptimal keyboard layout may result.");
|
||||
this.OS='MacOSX';
|
||||
} else if(results.length > 1 && results[1]) {
|
||||
// Convert version string into a usable form.
|
||||
let versionString = results[1].replace('_', '.');
|
||||
let version = new utils.Version(versionString);
|
||||
if(agent.indexOf('iPad') >= 0) {
|
||||
this.OS='iOS';
|
||||
this.formFactor='tablet';
|
||||
this.dyPortrait=this.dyLandscape=0;
|
||||
} else if(agent.indexOf('iPhone') >= 0) {
|
||||
this.OS='iOS';
|
||||
this.formFactor='phone';
|
||||
this.dyPortrait=this.dyLandscape=25;
|
||||
} else if(agent.indexOf('Android') >= 0) {
|
||||
this.OS='Android';
|
||||
this.formFactor='phone'; // form factor may be redefined on initialization
|
||||
this.dyPortrait=75;
|
||||
this.dyLandscape=25;
|
||||
try {
|
||||
var rx=new RegExp("(?:Android\\s+)(\\d+\\.\\d+\\.\\d+)");
|
||||
this.version=agent.match(rx)[1];
|
||||
} catch(ex) {}
|
||||
} else if(agent.indexOf('Linux') >= 0) {
|
||||
this.OS='Linux';
|
||||
} else if(agent.indexOf('Macintosh') >= 0) {
|
||||
// Starting with 13.1, "Macintosh" can reflect iPads (by default) or iPhones
|
||||
// (by user setting); a new "Request Desktop Website" setting for Safari will
|
||||
// change the user agent string to match a desktop Mac.
|
||||
//
|
||||
// Firefox uses '.' between version components, while Chrome and Safari use
|
||||
// '_' instead. So, we have to check for both. Yay.
|
||||
let regex = /Intel Mac OS X (\d+(?:[_\.]\d+)+)/i;
|
||||
let results = regex.exec(agent);
|
||||
|
||||
possMacSpoof = utils.Version.MAC_POSSIBLE_IPAD_ALIAS.compareTo(version) <= 0;
|
||||
this.OS='MacOSX';
|
||||
}
|
||||
} else if(agent.indexOf('Windows NT') >= 0) {
|
||||
this.OS='Windows';
|
||||
if(agent.indexOf('Touch') >= 0) {
|
||||
this.formFactor='phone'; // will be redefined as tablet if resolution high enough
|
||||
}
|
||||
// Match result: a version string with components separated by underscores.
|
||||
if(!results) {
|
||||
console.warn("KMW could not properly parse the user agent string."
|
||||
+ "A suboptimal keyboard layout may result.");
|
||||
this.OS='MacOSX';
|
||||
} else if(results.length > 1 && results[1]) {
|
||||
// Convert version string into a usable form.
|
||||
let versionString = results[1].replace('_', '.');
|
||||
let version = new Version(versionString);
|
||||
|
||||
// Windows Phone and Tablet PC
|
||||
if(typeof (<any>navigator).msMaxTouchPoints == 'number' && (<any>navigator).msMaxTouchPoints > 0) {
|
||||
this.touchable=true;
|
||||
}
|
||||
possMacSpoof = Version.MAC_POSSIBLE_IPAD_ALIAS.compareTo(version) <= 0;
|
||||
this.OS='MacOSX';
|
||||
}
|
||||
} else if(agent.indexOf('Windows NT') >= 0) {
|
||||
this.OS='Windows';
|
||||
if(agent.indexOf('Touch') >= 0) {
|
||||
this.formFactor='phone'; // will be redefined as tablet if resolution high enough
|
||||
}
|
||||
|
||||
// Windows Phone and Tablet PC
|
||||
if(typeof (<any>navigator).msMaxTouchPoints == 'number' && (<any>navigator).msMaxTouchPoints > 0) {
|
||||
this.touchable=true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We look at the screen resolution for Android, because we can't tell from
|
||||
// the user agent string whether or not this is supposed to be a tablet.
|
||||
// It seems that there are a handful of older phones out there that report a
|
||||
// higher resolution than 700px*___px, but it is proving hard to test these,
|
||||
// and the majority have an aspect ratio <= 0.5625 anyway.
|
||||
// But we trust what iOS tells us for phone vs tablet.
|
||||
// We look at the screen resolution for Android, because we can't tell from
|
||||
// the user agent string whether or not this is supposed to be a tablet.
|
||||
// It seems that there are a handful of older phones out there that report a
|
||||
// higher resolution than 700px*___px, but it is proving hard to test these,
|
||||
// and the majority have an aspect ratio <= 0.5625 anyway.
|
||||
// But we trust what iOS tells us for phone vs tablet.
|
||||
|
||||
const dimMin = Math.min(screen.width,screen.height), dimMax = Math.max(screen.width,screen.height);
|
||||
const aspect = dimMin / dimMax;
|
||||
const dimMin = Math.min(screen.width,screen.height), dimMax = Math.max(screen.width,screen.height);
|
||||
const aspect = dimMin / dimMax;
|
||||
|
||||
if(this.OS != 'iOS' &&
|
||||
this.formFactor == 'phone' &&
|
||||
((dimMin >= 600 && aspect > 0.5625) || // 0.5625 -> 1920x1080 is common phone res
|
||||
(aspect >= 0.625)) // all reported devices with aspect >= 0.625 are tablets per https://screensiz.es/
|
||||
) {
|
||||
this.formFactor='tablet';
|
||||
}
|
||||
if(this.OS != 'iOS' &&
|
||||
this.formFactor == 'phone' &&
|
||||
((dimMin >= 600 && aspect > 0.5625) || // 0.5625 -> 1920x1080 is common phone res
|
||||
(aspect >= 0.625)) // all reported devices with aspect >= 0.625 are tablets per https://screensiz.es/
|
||||
) {
|
||||
this.formFactor='tablet';
|
||||
}
|
||||
|
||||
// Test for potential Chrome emulation on Windows or macOS X (used only in next if-check)
|
||||
let possibleChromeEmulation = navigator.platform == 'Win32' || navigator.platform == 'MacIntel'
|
||||
// Test for potential Chrome emulation on Windows or macOS X (used only in next if-check)
|
||||
let possibleChromeEmulation = navigator.platform == 'Win32' || navigator.platform == 'MacIntel'
|
||||
|
||||
// alert(sxx+'->'+device.formFactor);
|
||||
// Check for phony iOS devices (but don't undo for Chrome emulation used during development)
|
||||
if(this.OS == 'iOS' && !('ongesturestart' in window) && !possibleChromeEmulation) {
|
||||
this.OS='Android';
|
||||
}
|
||||
// alert(sxx+'->'+device.formFactor);
|
||||
// Check for phony iOS devices (but don't undo for Chrome emulation used during development)
|
||||
if(this.OS == 'iOS' && !('ongesturestart' in window) && !possibleChromeEmulation) {
|
||||
this.OS='Android';
|
||||
}
|
||||
|
||||
// Determine application or browser
|
||||
this.browser='web';
|
||||
if(this.OS == 'iOS' || this.OS.toLowerCase() == 'macosx') {
|
||||
// Determine application or browser
|
||||
this.browser='web';
|
||||
if(this.OS == 'iOS' || this.OS.toLowerCase() == 'macosx') {
|
||||
this.browser='safari';
|
||||
}
|
||||
|
||||
var bMatch=/Firefox|Chrome|OPR|Safari|Edge/;
|
||||
if(bMatch.test(navigator.userAgent)) {
|
||||
if((navigator.userAgent.indexOf('Firefox') >= 0) && ('onmozorientationchange' in screen)) {
|
||||
this.browser='firefox';
|
||||
} else if(navigator.userAgent.indexOf('OPR') >= 0) {
|
||||
this.browser='opera';
|
||||
} else if(navigator.userAgent.indexOf(' Edge/') >= 0) {
|
||||
// Edge is too common a word, so test for Edge/ :)
|
||||
// Must come before Chrome and Safari test because
|
||||
// Edge pretends to be both
|
||||
this.browser='edge';
|
||||
} else if(navigator.userAgent.indexOf('Chrome') >= 0) {
|
||||
// This test must come before Safari test because on macOS,
|
||||
// Chrome also reports "Safari"
|
||||
this.browser='chrome';
|
||||
} else if(navigator.userAgent.indexOf('Safari') >= 0) {
|
||||
this.browser='safari';
|
||||
}
|
||||
|
||||
var bMatch=/Firefox|Chrome|OPR|Safari|Edge/;
|
||||
if(bMatch.test(navigator.userAgent)) {
|
||||
if((navigator.userAgent.indexOf('Firefox') >= 0) && ('onmozorientationchange' in screen)) {
|
||||
this.browser='firefox';
|
||||
} else if(navigator.userAgent.indexOf('OPR') >= 0) {
|
||||
this.browser='opera';
|
||||
} else if(navigator.userAgent.indexOf(' Edge/') >= 0) {
|
||||
// Edge is too common a word, so test for Edge/ :)
|
||||
// Must come before Chrome and Safari test because
|
||||
// Edge pretends to be both
|
||||
this.browser='edge';
|
||||
} else if(navigator.userAgent.indexOf('Chrome') >= 0) {
|
||||
// This test must come before Safari test because on macOS,
|
||||
// Chrome also reports "Safari"
|
||||
this.browser='chrome';
|
||||
} else if(navigator.userAgent.indexOf('Safari') >= 0) {
|
||||
this.browser='safari';
|
||||
}
|
||||
}
|
||||
|
||||
if(possMacSpoof && this.browser == 'safari') {
|
||||
// Indistinguishable user agent string! We need a different test; fortunately, true macOS
|
||||
// Safari doesn't support TouchEvents. (Chrome does, though! Hence the filter above.)
|
||||
if(window['TouchEvent']) {
|
||||
this.OS='iOS';
|
||||
this.formFactor='tablet';
|
||||
this.dyPortrait=this.dyLandscape=0;
|
||||
|
||||
// It's currently impossible to differentiate between iPhone and iPad here
|
||||
// except for by screen dimensions.
|
||||
let aspectRatio = screen.height / screen.width;
|
||||
if(aspectRatio < 1) {
|
||||
aspectRatio = 1 / aspectRatio;
|
||||
}
|
||||
|
||||
// iPhones usually have a ratio of 16:9 (or 1.778) or higher, while iPads use 4:3 (or 1.333)
|
||||
if(aspectRatio > 1.6) {
|
||||
// Override - we'll treat this device as an iPhone.
|
||||
this.formFactor = 'phone';
|
||||
this.dyPortrait=this.dyLandscape=25;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.colorScheme = utils.StyleConstants.prefersDarkMode() ? 'dark' : 'light';
|
||||
this.detected = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a slimmer, web-core compatible version of this object.
|
||||
*/
|
||||
public get coreSpec(): utils.DeviceSpec {
|
||||
return new utils.DeviceSpec(this.browser, this.formFactor, this.OS, this.touchable);
|
||||
if(possMacSpoof && this.browser == 'safari') {
|
||||
// Indistinguishable user agent string! We need a different test; fortunately, true macOS
|
||||
// Safari doesn't support TouchEvents. (Chrome does, though! Hence the filter above.)
|
||||
if(window['TouchEvent']) {
|
||||
this.OS='iOS';
|
||||
this.formFactor='tablet';
|
||||
this.dyPortrait=this.dyLandscape=0;
|
||||
|
||||
// It's currently impossible to differentiate between iPhone and iPad here
|
||||
// except for by screen dimensions.
|
||||
let aspectRatio = screen.height / screen.width;
|
||||
if(aspectRatio < 1) {
|
||||
aspectRatio = 1 / aspectRatio;
|
||||
}
|
||||
|
||||
// iPhones usually have a ratio of 16:9 (or 1.778) or higher, while iPads use 4:3 (or 1.333)
|
||||
if(aspectRatio > 1.6) {
|
||||
// Override - we'll treat this device as an iPhone.
|
||||
this.formFactor = 'phone';
|
||||
this.dyPortrait=this.dyLandscape=25;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.colorScheme = StyleConstants.prefersDarkMode() ? 'dark' : 'light';
|
||||
this.detected = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a slimmer, web-core compatible version of this object.
|
||||
*/
|
||||
public get coreSpec(): DeviceSpec {
|
||||
return new DeviceSpec(this.browser, this.formFactor, this.OS, this.touchable);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
|
||||
"compilerOptions": {
|
||||
"outFile": "../../../build/engine/device-detect/obj/index.bundled.js",
|
||||
},
|
||||
|
||||
"references": [
|
||||
{ "path": "../../../../common/web/keyman-version", "prepend": true },
|
||||
{ "path": "../../../../common/web/utils", "prepend": true }
|
||||
]
|
||||
}
|
||||
|
|
@ -2,18 +2,19 @@
|
|||
"extends": "../../../../tsconfig-base.json",
|
||||
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"allowJs": false,
|
||||
"inlineSources": true,
|
||||
"module": "none",
|
||||
"outFile": "../../../build/engine/device-detect/obj/index.js",
|
||||
"module": "es6",
|
||||
"moduleResolution": "Node",
|
||||
"sourceMap": true,
|
||||
"target": "es5"
|
||||
"target": "es5",
|
||||
"baseUrl": "./",
|
||||
"outDir": "../../../build/engine/device-detect/obj/",
|
||||
"tsBuildInfoFile": "../../../build/engine/device-detect/obj/tsconfig.tsbuildinfo",
|
||||
"rootDir": "."
|
||||
},
|
||||
|
||||
"files": [
|
||||
"kmwdevice.ts",
|
||||
"utils/styleConstants.ts", // goes with kmwdevice.ts
|
||||
],
|
||||
"include": [ "**/*.ts" ],
|
||||
|
||||
"references": [
|
||||
{ "path": "../../../../common/web/keyman-version" },
|
||||
|
|
|
|||
|
|
@ -1,34 +1,31 @@
|
|||
// Includes Device definitions, which may play a role in constant logic.
|
||||
///<reference path="../kmwdevice.ts" />
|
||||
import { DeviceSpec } from "@keymanapp/web-utils/build/obj/index.js";
|
||||
|
||||
/*
|
||||
* This file is intended for CSS-styling constants that see use with the OSK.
|
||||
*/
|
||||
|
||||
namespace com.keyman.utils {
|
||||
/**
|
||||
* Defines device-level constants used for CSS styling.
|
||||
*/
|
||||
export class StyleConstants {
|
||||
constructor(device: com.keyman.utils.DeviceSpec) {
|
||||
// popupCanvasBackgroundColor
|
||||
if(device.OS == utils.OperatingSystem.Android) {
|
||||
this.popupCanvasBackgroundColor = '#999';
|
||||
} else {
|
||||
this.popupCanvasBackgroundColor = StyleConstants.prefersDarkMode() ? '#0f1319' : '#ffffff';
|
||||
}
|
||||
/**
|
||||
* Defines device-level constants used for CSS styling.
|
||||
*/
|
||||
export default class StyleConstants {
|
||||
constructor(device: DeviceSpec) {
|
||||
// popupCanvasBackgroundColor
|
||||
if(device.OS == DeviceSpec.OperatingSystem.Android) {
|
||||
this.popupCanvasBackgroundColor = '#999';
|
||||
} else {
|
||||
this.popupCanvasBackgroundColor = StyleConstants.prefersDarkMode() ? '#0f1319' : '#ffffff';
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks is a user's browser is in dark mode, if the feature is supported. Returns false otherwise.
|
||||
*
|
||||
* Thanks to https://stackoverflow.com/a/57795518 for this code.
|
||||
*/
|
||||
static prefersDarkMode(): boolean {
|
||||
// Ensure the detector exists (otherwise, returns false)
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
public readonly popupCanvasBackgroundColor: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks is a user's browser is in dark mode, if the feature is supported. Returns false otherwise.
|
||||
*
|
||||
* Thanks to https://stackoverflow.com/a/57795518 for this code.
|
||||
*/
|
||||
static prefersDarkMode(): boolean {
|
||||
// Ensure the detector exists (otherwise, returns false)
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
public readonly popupCanvasBackgroundColor: string;
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue