From d6376f1ffb15367a99bd1e09ef39c4cab451f30e Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 17 Sep 2025 11:28:37 +0100 Subject: [PATCH 1/7] Add Keyboard Generator modal Allows for adding a keyboard - by language code in frequency or alphabetical order --- package.json | 3 +- src/js/service/keyboardGeneratorService.js | 142 +++++++++++++++++ .../modals/keyboardGeneratorModal.vue | 147 ++++++++++++++++++ src/vue-components/views/manageGridsView.vue | 13 +- yarn.lock | 5 + 5 files changed, 308 insertions(+), 2 deletions(-) create mode 100644 src/js/service/keyboardGeneratorService.js create mode 100644 src/vue-components/modals/keyboardGeneratorModal.vue diff --git a/package.json b/package.json index 754b4cb0c3..5e7c344007 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "superlogin-client": "^0.8.0", "vue": "^2.7.15", "vue-i18n": "8", - "vue-multiselect": "^2.1.8" + "vue-multiselect": "^2.1.8", + "worldalphabets": "0.0.21" }, "devDependencies": { "@babel/core": "^7.21.8", diff --git a/src/js/service/keyboardGeneratorService.js b/src/js/service/keyboardGeneratorService.js new file mode 100644 index 0000000000..39ea87ff22 --- /dev/null +++ b/src/js/service/keyboardGeneratorService.js @@ -0,0 +1,142 @@ +import { GridData } from '../model/GridData'; +import { GridElement } from '../model/GridElement'; +import { i18nService } from './i18nService'; +import * as WA from 'worldalphabets'; + +async function getAvailableCodes() { + return WA.getAvailableCodes ? WA.getAvailableCodes() : []; +} + +async function getScripts(langCode) { + try { + if (WA.getScripts) return await WA.getScripts(langCode); + if (WA.getLanguage) { + const lang = await WA.getLanguage(langCode); + return lang && lang.scripts ? lang.scripts : []; + } + } catch (e) {} + return []; +} + +function toLocaleTag(langCode, script) { + // Build BCP-47 tag with script subtag when provided (e.g., sr-Cyrl) + if (script && typeof script === 'string' && script.length >= 4) { + const scriptTag = script[0].toUpperCase() + script.slice(1).toLowerCase(); + return `${langCode}-${scriptTag}`; + } + return langCode; +} + +async function getCharacters(langCode, script, { includeDigits = false } = {}) { + let lower = []; + if (WA.getLowercase) { + lower = await WA.getLowercase(langCode, script); + } else if (WA.getLanguage) { + const lang = await WA.getLanguage(langCode); + const alph = lang && lang.alphabet ? lang.alphabet : {}; + lower = alph.lowercase || alph.lower || alph.base || []; + } + let chars = Array.isArray(lower) ? lower.slice() : []; + const seen = new Set(); + chars = chars.filter((c) => typeof c === 'string' && c.length > 0 && !seen.has(c) && seen.add(c)); + + if (includeDigits) { + try { + let digitList = []; + if (WA.getDigits) { + const digits = await WA.getDigits(langCode, script); + digitList = Array.isArray(digits) ? digits : (typeof digits === 'object' && digits ? Object.values(digits) : []); + } else if (WA.getLanguage) { + const lang = await WA.getLanguage(langCode); + digitList = lang && Array.isArray(lang.digits) ? lang.digits : []; + } + for (const d of digitList) { + if (typeof d === 'string' && d.length > 0 && !seen.has(d)) { + seen.add(d); + chars.push(d); + } + } + } catch (e) {} + } + return chars; +} + +async function orderCharacters(langCode, chars, order, script) { + if (order === 'frequency') { + try { + let freqMap = {}; + if (WA.getFrequency) { + freqMap = await WA.getFrequency(langCode); + } else if (WA.getLanguage) { + const lang = await WA.getLanguage(langCode); + freqMap = (lang && lang.frequency) || {}; + } + const getF = (c) => { + const key = (c || '').toLowerCase(); + return typeof freqMap?.[key] === 'number' ? freqMap[key] : 0; + }; + return chars.slice().sort((a, b) => getF(b) - getF(a)); + } catch (e) { + // fallback to alphabetical if frequency absent + } + } + const collator = new Intl.Collator(toLocaleTag(langCode, script), { usage: 'sort', sensitivity: 'base', numeric: false }); + return chars.slice().sort((a, b) => collator.compare(a, b)); +} + +function computeGridDims(n, rows, cols) { + if (rows && cols) return { rows, cols }; + if (cols && !rows) return { cols, rows: Math.ceil(n / cols) }; + if (rows && !cols) return { rows, cols: Math.ceil(n / rows) }; + const approxCols = Math.max(3, Math.min(12, Math.ceil(Math.sqrt(n)))); + return { cols: approxCols, rows: Math.ceil(n / approxCols) }; +} + +async function generateKeyboardGrid({ + langCode, + script = undefined, + order = 'frequency', // 'frequency' | 'alphabetical' + includeDigits = false, + gridLabel = null, + rows = null, + cols = null, +} = {}) { + if (!langCode) throw new Error('langCode is required'); + const charsRaw = await getCharacters(langCode, script, { includeDigits }); + const chars = await orderCharacters(langCode, charsRaw, order, script); + + const { rows: R, cols: C } = computeGridDims(chars.length, rows, cols); + + const label = gridLabel || `Keyboard ${langCode}${script ? '-' + script : ''} (${order})`; + const grid = new GridData({ + label: i18nService.getTranslationObject(label), + gridElements: [], + rowCount: R, + minColumnCount: C, + keyboardMode: GridData.KEYBOARD_ENABLED, + }); + + let i = 0; + for (const ch of chars) { + const x = i % C; + const y = Math.floor(i / C); + grid.gridElements.push( + new GridElement({ + label: i18nService.getTranslationObject(ch), + x, + y, + width: 1, + height: 1, + }) + ); + i += 1; + } + return grid; +} + +export const keyboardGeneratorService = { + getAvailableCodes, + getScripts, + generateKeyboardGrid, +}; + diff --git a/src/vue-components/modals/keyboardGeneratorModal.vue b/src/vue-components/modals/keyboardGeneratorModal.vue new file mode 100644 index 0000000000..6229ea19ae --- /dev/null +++ b/src/vue-components/modals/keyboardGeneratorModal.vue @@ -0,0 +1,147 @@ + + + + + + diff --git a/src/vue-components/views/manageGridsView.vue b/src/vue-components/views/manageGridsView.vue index 3c630e0f1d..8933fab9ec 100644 --- a/src/vue-components/views/manageGridsView.vue +++ b/src/vue-components/views/manageGridsView.vue @@ -110,6 +110,7 @@ +
@@ -139,6 +140,7 @@ import { urlParamService } from '../../js/service/urlParamService'; import { GridImage } from '../../js/model/GridImage'; + import KeyboardGeneratorModal from "../modals/keyboardGeneratorModal.vue"; let ORDER_MODE_KEY = "AG_ALLGRIDS_ORDER_MODE_KEY"; let SELECTOR_CONTEXTMENU = '#moreButton'; let SELECT_VALUES = { @@ -154,7 +156,7 @@ let vueApp = null; let vueConfig = { components: { - NoGridsPage, ImportModal, ExportModal, ExportPdfModal, GridLinkModal, Accordion, HeaderIcon}, + NoGridsPage, ImportModal, ExportModal, ExportPdfModal, GridLinkModal, Accordion, HeaderIcon, KeyboardGeneratorModal}, data() { return { metadata: null, @@ -183,6 +185,9 @@ importModal: { show: false }, + keyboardGenModal: { + show: false + }, i18nService: i18nService, currentLanguage: i18nService.getContentLang(), imageUtil: imageUtil @@ -641,6 +646,7 @@ //see https://swisnl.github.io/jQuery-contextMenu/demo.html var CONTEXT_NEW = "CONTEXT_NEW"; + var CONTEXT_GENERATE_KEYBOARD = "CONTEXT_GENERATE_KEYBOARD"; var CONTEXT_EXPORT = "CONTEXT_EXPORT"; var CONTEXT_EXPORT_CUSTOM = "CONTEXT_EXPORT_CUSTOM"; var CONTEXT_IMPORT = "CONTEXT_IMPORT"; @@ -651,6 +657,7 @@ let noGrids = (() => vueApp.grids.length === 0); var itemsMoreMenu = { CONTEXT_NEW: {name: i18nService.t('newGrid'), icon: "fas fa-plus"}, + CONTEXT_GENERATE_KEYBOARD: {name: 'Generate keyboard…', icon: 'fas fa-keyboard'}, SEP1: "---------", CONTEXT_EXPORT: { name: i18nService.t('exportBackupToFile'), @@ -697,6 +704,10 @@ vueApp.addGrid(); break; } + case CONTEXT_GENERATE_KEYBOARD: { + vueApp.keyboardGenModal.show = true; + break; + } case CONTEXT_IMPORT: { vueApp.importModal.show = true; break; diff --git a/yarn.lock b/yarn.lock index 34d1e2b853..3a01f38ac1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5560,6 +5560,11 @@ word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f" integrity sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA== +worldalphabets@0.0.21: + version "0.0.21" + resolved "https://registry.yarnpkg.com/worldalphabets/-/worldalphabets-0.0.21.tgz#8abcb8e0d2047e79c916ad321f1015fa04a51b2c" + integrity sha512-aqXVjddSauo8brGboTfxwgu6DDoLu8sfNpbxua9pTT9HAcI6+VAixOeDiL3+LRWpeJ35XC+NVpaQDUY5HYwGlg== + wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" From 958a6f798bdadfc4b046ec8fd1035fd1121259e2 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 17 Sep 2025 11:33:16 +0100 Subject: [PATCH 2/7] display by language names. --- .../modals/keyboardGeneratorModal.vue | 56 ++++++++++++++++++- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/src/vue-components/modals/keyboardGeneratorModal.vue b/src/vue-components/modals/keyboardGeneratorModal.vue index 6229ea19ae..c7ebd1e7a9 100644 --- a/src/vue-components/modals/keyboardGeneratorModal.vue +++ b/src/vue-components/modals/keyboardGeneratorModal.vue @@ -12,7 +12,7 @@
@@ -20,7 +20,7 @@ @@ -84,7 +84,9 @@ export default { return { loading: false, codes: [], + codeOptions: [], scripts: [], + scriptOptions: [], langCode: '', script: undefined, order: 'frequency', @@ -100,15 +102,63 @@ export default { } }, methods: { + bcpTag(code) { + if (!code) return code; + const parts = code.split('-'); + if (parts.length === 2) { + const p1 = parts[0]; + const p2 = parts[1]; + if (p2.length === 2) return `${p1}-${p2.toUpperCase()}`; + if (p2.length === 4) return `${p1}-${p2[0].toUpperCase()}${p2.slice(1).toLowerCase()}`; + } + return code; + }, + langDisplay(code) { + try { + const byI18n = i18nService.getLangReadable && i18nService.getLangReadable(code); + if (byI18n && typeof byI18n === 'string' && byI18n.trim()) return `${byI18n} (${code})`; + } catch (e) {} + try { + if (window.Intl && Intl.DisplayNames) { + const ui = (i18nService.getAppLang && i18nService.getAppLang()) || navigator.language || 'en'; + const dn = new Intl.DisplayNames([ui], { type: 'language' }); + const name = dn.of(this.bcpTag(code)); + if (name) return `${name} (${code})`; + } + } catch (e) {} + return code; + }, + scriptDisplay(s) { + if (!s) return s; + try { + if (window.Intl && Intl.DisplayNames) { + const ui = (i18nService.getAppLang && i18nService.getAppLang()) || navigator.language || 'en'; + const dn = new Intl.DisplayNames([ui], { type: 'script' }); + const name = dn.of(s[0].toUpperCase() + s.slice(1).toLowerCase()); + if (name) return `${name} (${s})`; + } + } catch (e) {} + return s; + }, + buildLangOptions() { + this.codeOptions = (this.codes || []).map(c => ({ code: c, label: this.langDisplay(c) })) + .sort((a,b) => (''+a.label).localeCompare(b.label)); + }, + buildScriptOptions() { + this.scriptOptions = (this.scripts || []).map(s => ({ value: s, label: this.scriptDisplay(s) })) + .sort((a,b) => (''+a.label).localeCompare(b.label)); + }, async loadCodes() { this.codes = await keyboardGeneratorService.getAvailableCodes(); + this.buildLangOptions(); const current = i18nService.getContentLang && i18nService.getContentLang(); if (current && this.codes.includes(current)) this.langCode = current; else this.langCode = this.codes[0] || ''; await this.onLangChange(); }, async onLangChange() { - if (!this.langCode) { this.scripts = []; this.script = undefined; return; } + if (!this.langCode) { this.scripts = []; this.scriptOptions = []; this.script = undefined; return; } this.scripts = await keyboardGeneratorService.getScripts(this.langCode); + this.buildScriptOptions(); if (!this.scripts || this.scripts.length === 0) this.script = undefined; }, async generate() { From 56b3d385ed1390b99fe4af72d0a74fbd18245bbd Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 17 Sep 2025 14:27:20 +0100 Subject: [PATCH 3/7] hide digits if not available --- src/js/service/keyboardGeneratorService.js | 32 +++++++++++++++++++ .../modals/keyboardGeneratorModal.vue | 24 +++++++++++--- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/js/service/keyboardGeneratorService.js b/src/js/service/keyboardGeneratorService.js index 39ea87ff22..33a68fb4ed 100644 --- a/src/js/service/keyboardGeneratorService.js +++ b/src/js/service/keyboardGeneratorService.js @@ -134,9 +134,41 @@ async function generateKeyboardGrid({ return grid; } +async function supportsDigits(langCode, script) { + try { + if (WA.getDigits) { + const res = await WA.getDigits(langCode, script); + if (Array.isArray(res)) return res.length > 0; + if (res && typeof res === 'object') return Object.values(res).length > 0; + return false; + } + if (WA.getLanguage) { + const lang = await WA.getLanguage(langCode); + return !!(lang && Array.isArray(lang.digits) && lang.digits.length > 0); + } + } catch (e) {} + return false; +} + +async function supportsFrequency(langCode) { + try { + if (WA.getFrequency) { + const res = await WA.getFrequency(langCode); + return !!(res && typeof res === 'object' && Object.keys(res).length > 0); + } + if (WA.getLanguage) { + const lang = await WA.getLanguage(langCode); + return !!(lang && lang.frequency && Object.keys(lang.frequency).length > 0); + } + } catch (e) {} + return false; +} + export const keyboardGeneratorService = { getAvailableCodes, getScripts, generateKeyboardGrid, + supportsDigits, + supportsFrequency, }; diff --git a/src/vue-components/modals/keyboardGeneratorModal.vue b/src/vue-components/modals/keyboardGeneratorModal.vue index c7ebd1e7a9..5b5cc51fd6 100644 --- a/src/vue-components/modals/keyboardGeneratorModal.vue +++ b/src/vue-components/modals/keyboardGeneratorModal.vue @@ -18,7 +18,7 @@
- @@ -27,7 +27,7 @@
- +
@@ -47,7 +47,7 @@
-
+
@@ -91,6 +91,8 @@ export default { script: undefined, order: 'frequency', includeDigits: false, + supportsDigits: false, + supportsFrequency: false, rows: null, cols: null, gridLabel: '', @@ -154,12 +156,26 @@ export default { const current = i18nService.getContentLang && i18nService.getContentLang(); if (current && this.codes.includes(current)) this.langCode = current; else this.langCode = this.codes[0] || ''; await this.onLangChange(); + await this.checkCapabilities(); }, async onLangChange() { - if (!this.langCode) { this.scripts = []; this.scriptOptions = []; this.script = undefined; return; } + if (!this.langCode) { this.scripts = []; this.scriptOptions = []; this.script = undefined; this.supportsDigits=false; this.supportsFrequency=false; return; } this.scripts = await keyboardGeneratorService.getScripts(this.langCode); this.buildScriptOptions(); if (!this.scripts || this.scripts.length === 0) this.script = undefined; + await this.checkCapabilities(); + }, + async onScriptChange() { + await this.checkCapabilities(); + }, + async checkCapabilities() { + try { + this.supportsDigits = await keyboardGeneratorService.supportsDigits(this.langCode, this.script); + } catch (e) { this.supportsDigits = false; } + try { + this.supportsFrequency = await keyboardGeneratorService.supportsFrequency(this.langCode); + } catch (e) { this.supportsFrequency = false; } + if (!this.supportsFrequency && this.order === 'frequency') this.order = 'alphabetical'; }, async generate() { if (!this.langCode) return; From 031627c8d91e83beba7a22b2a2a7047cdce9bb58 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 17 Sep 2025 15:03:49 +0100 Subject: [PATCH 4/7] do two hit when possible. --- src/js/service/keyboardGeneratorService.js | 189 +++++++++++++++--- .../modals/keyboardGeneratorModal.vue | 23 ++- 2 files changed, 183 insertions(+), 29 deletions(-) diff --git a/src/js/service/keyboardGeneratorService.js b/src/js/service/keyboardGeneratorService.js index 33a68fb4ed..f9e7d8639b 100644 --- a/src/js/service/keyboardGeneratorService.js +++ b/src/js/service/keyboardGeneratorService.js @@ -1,5 +1,6 @@ import { GridData } from '../model/GridData'; import { GridElement } from '../model/GridElement'; +import { GridActionNavigate } from '../model/GridActionNavigate'; import { i18nService } from './i18nService'; import * as WA from 'worldalphabets'; @@ -91,47 +92,182 @@ function computeGridDims(n, rows, cols) { const approxCols = Math.max(3, Math.min(12, Math.ceil(Math.sqrt(n)))); return { cols: approxCols, rows: Math.ceil(n / approxCols) }; } +function computePageDims(n, rows, cols) { + if (rows && cols) return { rows, cols }; + if (rows && !cols) return { rows, cols: 10 }; + if (!rows && cols) return { rows: 8, cols }; + return { rows: 8, cols: 10 }; +} + +function chunkArray(arr, size) { + const chunks = []; + for (let i = 0; i < arr.length; i += size) chunks.push(arr.slice(i, i + size)); + return chunks; +} -async function generateKeyboardGrid({ +async function generateKeyboardGrids({ langCode, script = undefined, - order = 'frequency', // 'frequency' | 'alphabetical' + order = 'frequency', includeDigits = false, gridLabel = null, rows = null, cols = null, + twoHit = false, } = {}) { if (!langCode) throw new Error('langCode is required'); const charsRaw = await getCharacters(langCode, script, { includeDigits }); + if (!charsRaw || charsRaw.length === 0) { + throw new Error('No characters available for the selected language/script.'); + } const chars = await orderCharacters(langCode, charsRaw, order, script); - const { rows: R, cols: C } = computeGridDims(chars.length, rows, cols); + const labelBase = gridLabel || `Keyboard ${langCode}${script ? '-' + script : ''} (${order})`; - const label = gridLabel || `Keyboard ${langCode}${script ? '-' + script : ''} (${order})`; - const grid = new GridData({ - label: i18nService.getTranslationObject(label), - gridElements: [], - rowCount: R, - minColumnCount: C, - keyboardMode: GridData.KEYBOARD_ENABLED, - }); + const { rows: R, cols: C } = computePageDims(chars.length, rows, cols); + if (!R || !C || R < 1 || C < 1) throw new Error('Invalid grid dimensions'); + + const capacityFull = R * C; + if (chars.length <= capacityFull) { + const grid = new GridData({ + label: i18nService.getTranslationObject(labelBase), + gridElements: [], + rowCount: R, + minColumnCount: C, + keyboardMode: GridData.KEYBOARD_ENABLED, + }); + let i = 0; + for (const ch of chars) { + const x = i % C; + const y = Math.floor(i / C); + grid.gridElements.push( + new GridElement({ + label: i18nService.getTranslationObject(ch), + x, + y, + width: 1, + height: 1, + }) + ); + i += 1; + } + return [grid]; + } - let i = 0; - for (const ch of chars) { - const x = i % C; - const y = Math.floor(i / C); - grid.gridElements.push( - new GridElement({ - label: i18nService.getTranslationObject(ch), - x, - y, - width: 1, - height: 1, - }) - ); - i += 1; + // Two-hit: try to fit into 2 pages; if still too big, fall back to regular pagination + if (twoHit && chars.length <= capacityFull * 2) { + const mid = Math.ceil(chars.length / 2); + const halves = [chars.slice(0, mid), chars.slice(mid)]; + const grids = halves.map((chunk, idx) => { + const label = `${labelBase} [${idx + 1}/2]`; + return new GridData({ + label: i18nService.getTranslationObject(label), + gridElements: [], + rowCount: R, + minColumnCount: C, + keyboardMode: GridData.KEYBOARD_ENABLED, + }); + }); + // Link pages and fill content + halves.forEach((chunk, idx) => { + const grid = grids[idx]; + const hasPrev = idx > 0; + const hasNext = idx < grids.length - 1; + if (hasPrev) { + grid.gridElements.push(new GridElement({ + label: i18nService.getTranslationObject('Prev'), x: 0, y: 0, width: 1, height: 1, + actions: [new GridActionNavigate({ navType: GridActionNavigate.NAV_TYPES.TO_GRID, toGridId: grids[idx - 1].id })], + })); + } + if (hasNext) { + grid.gridElements.push(new GridElement({ + label: i18nService.getTranslationObject('More'), x: 1, y: 0, width: 1, height: 1, + actions: [new GridActionNavigate({ navType: GridActionNavigate.NAV_TYPES.TO_GRID, toGridId: grids[idx + 1].id })], + })); + } + let placed = 0; + for (let y = 0; y < R; y++) { + for (let x = 0; x < C; x++) { + if ((hasPrev && x === 0 && y === 0) || (hasNext && x === 1 && y === 0)) continue; + if (placed >= chunk.length) break; + const ch = chunk[placed++]; + grid.gridElements.push(new GridElement({ label: i18nService.getTranslationObject(ch), x, y, width: 1, height: 1 })); + } + } + }); + return grids; } - return grid; + + const conservativeCapacity = Math.max(1, capacityFull - 2); + const chunks = chunkArray(chars, conservativeCapacity); + + const grids = chunks.map((chunk, idx) => { + const label = `${labelBase} [${idx + 1}/${chunks.length}]`; + return new GridData({ + label: i18nService.getTranslationObject(label), + gridElements: [], + rowCount: R, + minColumnCount: C, + keyboardMode: GridData.KEYBOARD_ENABLED, + }); + }); + + grids.forEach((grid, idx) => { + const hasPrev = idx > 0; + const hasNext = idx < grids.length - 1; + + if (hasPrev) { + grid.gridElements.push( + new GridElement({ + label: i18nService.getTranslationObject('Prev'), + x: 0, + y: 0, + width: 1, + height: 1, + actions: [new GridActionNavigate({ navType: GridActionNavigate.NAV_TYPES.TO_GRID, toGridId: grids[idx - 1].id })], + }) + ); + } + if (hasNext) { + grid.gridElements.push( + new GridElement({ + label: i18nService.getTranslationObject('More'), + x: 1, + y: 0, + width: 1, + height: 1, + actions: [new GridActionNavigate({ navType: GridActionNavigate.NAV_TYPES.TO_GRID, toGridId: grids[idx + 1].id })], + }) + ); + } + + const chunk = chunks[idx]; + let placed = 0; + for (let y = 0; y < R; y++) { + for (let x = 0; x < C; x++) { + if ((hasPrev && x === 0 && y === 0) || (hasNext && x === 1 && y === 0)) continue; + if (placed >= chunk.length) break; + const ch = chunk[placed++]; + grid.gridElements.push( + new GridElement({ + label: i18nService.getTranslationObject(ch), + x, + y, + width: 1, + height: 1, + }) + ); + } + } + }); + + return grids; +} + + +async function generateKeyboardGrid(opts = {}) { + const pages = await generateKeyboardGrids(opts); + return pages[0]; } async function supportsDigits(langCode, script) { @@ -167,6 +303,7 @@ async function supportsFrequency(langCode) { export const keyboardGeneratorService = { getAvailableCodes, getScripts, + generateKeyboardGrids, generateKeyboardGrid, supportsDigits, supportsFrequency, diff --git a/src/vue-components/modals/keyboardGeneratorModal.vue b/src/vue-components/modals/keyboardGeneratorModal.vue index 5b5cc51fd6..d00c8834b5 100644 --- a/src/vue-components/modals/keyboardGeneratorModal.vue +++ b/src/vue-components/modals/keyboardGeneratorModal.vue @@ -9,6 +9,9 @@
+
+ +
+ + +
+
+
@@ -101,8 +109,10 @@ export default { order: 'frequency', includeDigits: false, twoHit: false, + letterCase: 'lower', supportsDigits: false, supportsFrequency: false, + supportsUppercase: false, rows: null, cols: null, gridLabel: '', @@ -185,7 +195,11 @@ export default { try { this.supportsFrequency = await keyboardGeneratorService.supportsFrequency(this.langCode); } catch (e) { this.supportsFrequency = false; } + try { + this.supportsUppercase = await keyboardGeneratorService.supportsUppercase(this.langCode, this.script); + } catch (e) { this.supportsUppercase = false; } if (!this.supportsFrequency && this.order === 'frequency') this.order = 'alphabetical'; + if (!this.supportsUppercase && this.letterCase === 'upper') this.letterCase = 'lower'; }, async generate() { if (!this.langCode) return; @@ -201,6 +215,7 @@ export default { rows: this.rows || undefined, cols: this.cols || undefined, twoHit: this.twoHit, + letterCase: this.letterCase, }); for (const g of grids) { // Save sequentially to preserve order and avoid overwhelming storage From 4bd29a88d351eb693cd14e6eaee39426b84d8a13 Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 17 Sep 2025 17:17:08 +0100 Subject: [PATCH 6/7] add in official keyboard layout maker --- app/lang/i18n.en.json | 7 + src/js/service/keyboardGeneratorService.js | 147 ++++++++++++++++++ .../modals/keyboardGeneratorModal.vue | 120 +++++++++++--- 3 files changed, 253 insertions(+), 21 deletions(-) diff --git a/app/lang/i18n.en.json b/app/lang/i18n.en.json index 711d7716fa..500cae8235 100644 --- a/app/lang/i18n.en.json +++ b/app/lang/i18n.en.json @@ -191,6 +191,13 @@ "letterCase": "Letter case", "lowercase": "Lowercase", "uppercase": "Uppercase", + "layoutSource": "Layout source", + "generated": "Generated", + "officialLayout": "Official keyboard layout", + "template": "Template", + "noTemplatesForLanguage": "No official layouts available for this language.", + "hideNumberRow": "Hide number row", + "hidePunctuation": "Hide punctuation", "hideElement": "Hide element", "image": "Image", diff --git a/src/js/service/keyboardGeneratorService.js b/src/js/service/keyboardGeneratorService.js index 158bbd4d6f..e93361c86e 100644 --- a/src/js/service/keyboardGeneratorService.js +++ b/src/js/service/keyboardGeneratorService.js @@ -122,6 +122,151 @@ function chunkArray(arr, size) { return chunks; } +// ---------- Official keyboard layouts (templates) ---------- +function getEnglishLangName(langCode) { + try { + if (window && window.Intl && Intl.DisplayNames) { + const dn = new Intl.DisplayNames(['en'], { type: 'language' }); + // Normalize like 'en', 'sr-Cyrl' + const name = dn.of(langCode); + return (name || '').toLowerCase(); + } + } catch (e) {} + return ''; +} + +function isLetter(ch) { + if (!ch || typeof ch !== 'string') return false; + if (ch.length !== 1) return false; + // Works for many scripts: letters change with case; digits/punct/space don't + return ch.toUpperCase() !== ch.toLowerCase(); +} +function isAsciiDigit(ch) { + return typeof ch === 'string' && ch.length === 1 && ch >= '0' && ch <= '9'; +} +function isSpace(ch) { return ch === ' '; } +function isPunctuation(ch) { + if (!ch || typeof ch !== 'string' || ch.length !== 1) return false; + if (isSpace(ch) || isAsciiDigit(ch) || isLetter(ch)) return false; + return true; +} + +async function getAvailableTemplates(langCode /*, script */) { + if (!WA.getAvailableLayouts || !WA.loadKeyboard) return []; + const ids = await WA.getAvailableLayouts(); + const lc = (langCode || '').toLowerCase(); + const langName = getEnglishLangName(lc); + const candidates = ids.filter((id) => { + const s = String(id).toLowerCase(); + if (s.startsWith(lc + '-') || s.startsWith(lc + '_')) return true; + if (langName && s.includes('-' + langName)) return true; + return false; + }); + // Load small set to get human names + const result = []; + for (const id of candidates) { + try { + // eslint-disable-next-line no-await-in-loop + const kb = await WA.loadKeyboard(id); + const name = (kb && (kb.name || kb.id)) || id; + result.push({ id, name }); + } catch (e) { + result.push({ id, name: id }); + } + } + // Sort nicely by name + result.sort((a, b) => ('' + a.name).localeCompare(b.name)); + return result; +} + +function isAlnumBlockKeyPos(pos) { + if (!pos) return false; + return ( + pos.startsWith('Key') || + pos.startsWith('Digit') || + pos === 'Minus' || pos === 'Equal' || + pos === 'Backquote' || pos === 'IntlBackslash' || pos === 'Backslash' || + pos === 'BracketLeft' || pos === 'BracketRight' || + pos === 'Semicolon' || pos === 'Quote' || + pos === 'Comma' || pos === 'Period' || pos === 'Slash' || + pos === 'Space' + ); +} + +async function generateFromTemplate({ + layoutId, + langCode, + script, + letterCase = 'lower', + hideNumberRow = false, + hidePunctuation = false, + gridLabel = null, +} = {}) { + if (!layoutId) throw new Error('layoutId is required'); + const tag = toLocaleTag(langCode || '', script); + const kb = await WA.loadKeyboard(layoutId); + if (!kb || !Array.isArray(kb.keys)) throw new Error('Invalid keyboard layout'); + + // Build a pos->character map from base layer for the alphanumeric cluster + const posToChar = new Map(); + for (const k of kb.keys) { + if (k.dead || !k.legends || typeof k.legends.base !== 'string') continue; + const pos = k.pos || ''; + if (!isAlnumBlockKeyPos(pos)) continue; + let ch = (k.legends.base || '').toString(); + if (!ch) continue; + ch = ch[0]; + if (hidePunctuation && isPunctuation(ch)) continue; + if (isLetter(ch)) ch = (letterCase === 'upper') ? ch.toLocaleUpperCase(tag) : ch.toLocaleLowerCase(tag); + posToChar.set(pos, ch); + } + + // Define canonical row sequences by KeyboardEvent.code + const rowsCodes = [ + ['Backquote','Digit1','Digit2','Digit3','Digit4','Digit5','Digit6','Digit7','Digit8','Digit9','Digit0','Minus','Equal'], + ['KeyQ','KeyW','KeyE','KeyR','KeyT','KeyY','KeyU','KeyI','KeyO','KeyP','BracketLeft','BracketRight','Backslash'], + ['KeyA','KeyS','KeyD','KeyF','KeyG','KeyH','KeyJ','KeyK','KeyL','Semicolon','Quote'], + ['IntlBackslash','KeyZ','KeyX','KeyC','KeyV','KeyB','KeyN','KeyM','Comma','Period','Slash'], + ['Space'] + ]; + + const rowsData = []; + rowsCodes.forEach((codes, idx) => { + if (idx === 0 && hideNumberRow) return; // skip numbers row if requested + const row = []; + for (const code of codes) { + const ch = posToChar.get(code); + if (!ch) continue; + if (hidePunctuation && isPunctuation(ch) && code !== 'Space') continue; + row.push(ch); + } + if (row.length) rowsData.push(row); + }); + + if (!rowsData.length) throw new Error('No printable keys in the selected layout.'); + + const rowCount = rowsData.length; + const colCount = rowsData.reduce((m, r) => Math.max(m, r.length), 0); + + const labelBase = gridLabel || `Keyboard ${layoutId}${letterCase === 'upper' ? ' (UPPER)' : ''}`; + const grid = new GridData({ + label: i18nService.getTranslationObject(labelBase), + gridElements: [], + rowCount, + minColumnCount: colCount, + keyboardMode: GridData.KEYBOARD_ENABLED, + }); + + for (let y = 0; y < rowCount; y++) { + const row = rowsData[y]; + for (let x = 0; x < row.length; x++) { + const val = row[x]; + grid.gridElements.push(new GridElement({ label: i18nService.getTranslationObject(val), x, y, width: 1, height: 1 })); + } + } + return [grid]; +} + async function generateKeyboardGrids({ langCode, script = undefined, @@ -336,6 +481,8 @@ async function supportsFrequency(langCode) { export const keyboardGeneratorService = { getAvailableCodes, getScripts, + getAvailableTemplates, + generateFromTemplate, generateKeyboardGrids, generateKeyboardGrid, supportsDigits, diff --git a/src/vue-components/modals/keyboardGeneratorModal.vue b/src/vue-components/modals/keyboardGeneratorModal.vue index af58b26542..b5b507edf3 100644 --- a/src/vue-components/modals/keyboardGeneratorModal.vue +++ b/src/vue-components/modals/keyboardGeneratorModal.vue @@ -27,11 +27,25 @@
+
- +
- - + + +
+
+ + +
+ +
+ +
+ {{ $t('noTemplatesForLanguage') || 'No official layouts available for this language.' }} +
@@ -43,31 +57,48 @@ +
+ + + + + +
+ +
- - + +
+ + +
- + × - + (leave empty for auto)
- +
- +
+
+ + +
+ + +
+ +
+ +
+
+ +
- - + +
@@ -124,6 +141,8 @@ import './../../css/modal.css'; import { keyboardGeneratorService } from '../../js/service/keyboardGeneratorService'; import { dataService } from '../../js/service/data/dataService'; + + import { i18nService } from '../../js/service/i18nService'; export default { @@ -140,6 +159,8 @@ export default { layoutSource: 'generated', templateOptions: [], selectedLayoutId: '', + layer: 'base', + order: 'frequency', includeDigits: false, twoHit: false, @@ -281,6 +302,7 @@ export default { hideNumberRow: this.hideNumberRow, hidePunctuation: this.hidePunctuation, gridLabel: this.gridLabel || this.defaultLabel, + layer: this.layer, }); } else { grids = await keyboardGeneratorService.generateKeyboardGrids({ diff --git a/yarn.lock b/yarn.lock index 3a01f38ac1..705f299f7b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5560,10 +5560,10 @@ word-wrap@~1.2.3: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.4.tgz#cb4b50ec9aca570abd1f52f33cd45b6c61739a9f" integrity sha512-2V81OA4ugVo5pRo46hAoD2ivUJx8jXmWXfUkY4KFNw0hEptvN0QfH3K4nHiwzGeKl5rFKedV48QVoqYavy4YpA== -worldalphabets@0.0.21: - version "0.0.21" - resolved "https://registry.yarnpkg.com/worldalphabets/-/worldalphabets-0.0.21.tgz#8abcb8e0d2047e79c916ad321f1015fa04a51b2c" - integrity sha512-aqXVjddSauo8brGboTfxwgu6DDoLu8sfNpbxua9pTT9HAcI6+VAixOeDiL3+LRWpeJ35XC+NVpaQDUY5HYwGlg== +worldalphabets@0.0.23: + version "0.0.23" + resolved "https://registry.yarnpkg.com/worldalphabets/-/worldalphabets-0.0.23.tgz#b475409b6200c2dba3594dd7423b89e0c79e7aa3" + integrity sha512-ElEptPYe2Ia1zuiLidk7rxPGHu3wISG2YLeygTtXdUNU2HdPRBSGCXE0Q39IxxHkEBWi7aKIJvoXEQVSA6jWgA== wrap-ansi@^7.0.0: version "7.0.0"