From baf2d4bbb722463554cf0aead6d16ad0385904d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A1szl=C3=B3=20Monda?= Date: Wed, 8 Jul 2026 00:17:56 +0200 Subject: [PATCH 1/4] feat: add copy/paste layer support Adds copy and paste buttons to the layer header so a layer can be copied to a virtual clipboard and pasted onto any layer, overwriting it (closes #2692). Paste is safe across keymaps and devices: - macro references are re-linked by name, falling back to a none action - dangling switch-keymap references fall back to a none action - modules are merged by id and key count so a layer copied from a different keyboard or layout can never desync the target's structure Co-authored-by: Cursor --- .../keymap/edit/keymap-edit.component.html | 2 + .../keymap/edit/keymap-edit.component.ts | 3 + .../components/layers/layers.component.html | 30 +++- .../components/layers/layers.component.scss | 12 ++ .../app/components/layers/layers.component.ts | 16 +- .../svg/wrap/svg-keyboard-wrap.component.html | 4 + .../svg/wrap/svg-keyboard-wrap.component.ts | 12 +- .../uhk-web/src/app/store/actions/keymap.ts | 18 +++ .../src/app/store/effects/user-config.ts | 1 + packages/uhk-web/src/app/store/index.ts | 1 + .../app/store/reducers/user-configuration.ts | 143 ++++++++++++++++++ packages/uhk-web/src/styles/_global.scss | 5 + 12 files changed, 244 insertions(+), 3 deletions(-) diff --git a/packages/uhk-web/src/app/components/keymap/edit/keymap-edit.component.html b/packages/uhk-web/src/app/components/keymap/edit/keymap-edit.component.html index ef9acbe18ed..0f499326b74 100644 --- a/packages/uhk-web/src/app/components/keymap/edit/keymap-edit.component.html +++ b/packages/uhk-web/src/app/components/keymap/edit/keymap-edit.component.html @@ -6,8 +6,10 @@ [halvesInfo]="halvesInfo$ | async" [keyboardLayout]="keyboardLayout$ | async" [allowNewLayers]="true" + [allowLayerCopy]="true" [allowLayerDoubleTap]="allowLayerDoubleTap$ | async" [backlightingMode]="backlightingMode$ | async" + [canPasteLayer]="hasCopiedLayer$ | async" [currentLayer]="currentLayer$ | async" [isBacklightingColoring]="isBacklightingColoring$ | async" [lastEditedKey]="lastEditedKey$ | async" diff --git a/packages/uhk-web/src/app/components/keymap/edit/keymap-edit.component.ts b/packages/uhk-web/src/app/components/keymap/edit/keymap-edit.component.ts index ae1f658eaa6..b35622c142b 100644 --- a/packages/uhk-web/src/app/components/keymap/edit/keymap-edit.component.ts +++ b/packages/uhk-web/src/app/components/keymap/edit/keymap-edit.component.ts @@ -16,6 +16,7 @@ import { backlightingColorPalette, backlightingMode, getHalvesInfo, + getHasCopiedLayer, getKeyboardLayout, getLayerOptions, getSecondaryRoleOptions, @@ -57,6 +58,7 @@ export class KeymapEditComponent implements OnDestroy { backlightingMode$: Observable; currentLayer$: Observable; deletable$: Observable; + hasCopiedLayer$: Observable; isBacklightingColoring$: Observable; keymap$: Observable; keyboardLayout$: Observable; @@ -117,6 +119,7 @@ export class KeymapEditComponent implements OnDestroy { }); this.deletable$ = store.select(isKeymapDeletable); + this.hasCopiedLayer$ = store.select(getHasCopiedLayer); this.keyboardLayout$ = store.select(getKeyboardLayout); this.allowLayerDoubleTap$ = store.select(layerDoubleTapSupported); diff --git a/packages/uhk-web/src/app/components/layers/layers.component.html b/packages/uhk-web/src/app/components/layers/layers.component.html index 8489e01f0b2..06f62c59cda 100644 --- a/packages/uhk-web/src/app/components/layers/layers.component.html +++ b/packages/uhk-web/src/app/components/layers/layers.component.html @@ -27,6 +27,35 @@ + + + +
+ +
@@ -80,7 +109,6 @@
-
  • The Base layer is active by default (when no other layers are active). This layer can never be deleted.
  • diff --git a/packages/uhk-web/src/app/components/layers/layers.component.scss b/packages/uhk-web/src/app/components/layers/layers.component.scss index ced0d1aee56..138b1b24063 100644 --- a/packages/uhk-web/src/app/components/layers/layers.component.scss +++ b/packages/uhk-web/src/app/components/layers/layers.component.scss @@ -32,6 +32,10 @@ font-size: 18px; } + circle-tooltip { + align-self: center; + } + .btn-group { flex-wrap: wrap; .btn { @@ -39,5 +43,13 @@ } } } + + .layer-actions { + flex-wrap: wrap; + + .btn { + flex-grow: 0; + } + } } } diff --git a/packages/uhk-web/src/app/components/layers/layers.component.ts b/packages/uhk-web/src/app/components/layers/layers.component.ts index 9ce052116c1..89047a57ae9 100644 --- a/packages/uhk-web/src/app/components/layers/layers.component.ts +++ b/packages/uhk-web/src/app/components/layers/layers.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output, ViewChild } from '@angular/core'; -import { faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'; +import { faCopy, faPaste, faPlus, faTrash } from '@fortawesome/free-solid-svg-icons'; import { NgbTooltip } from '@ng-bootstrap/ng-bootstrap'; import { colord, RgbColor } from 'colord'; import { LayerName, RgbColorInterface } from 'uhk-common'; @@ -14,7 +14,9 @@ import { LayerOption, ModifyColorOfBacklightingColorPalettePayload } from '../.. changeDetection: ChangeDetectionStrategy.OnPush, }) export class LayersComponent { + @Input() allowLayerCopy = false; @Input() allowNewLayers: boolean; + @Input() canPasteLayer = false; @Input() current: LayerOption; @Input() layerOptions: LayerOption[]; @Input() paletteColors: Array = []; @@ -27,10 +29,14 @@ export class LayersComponent { @Output() selectLayer = new EventEmitter(); @Output() toggleColorFromPalette = new EventEmitter(); @Output() addLayer = new EventEmitter(); + @Output() copyLayer = new EventEmitter(); + @Output() pasteLayer = new EventEmitter(); @Output() removeLayer = new EventEmitter(); @ViewChild('deleteTooltip') deleteTooltip: NgbTooltip; + faCopy = faCopy; + faPaste = faPaste; faPlus = faPlus; faTrash = faTrash; LayerName = LayerName; @@ -56,6 +62,14 @@ export class LayersComponent { this.addLayer.emit(layerOption.id); } + onCopyLayer(): void { + this.copyLayer.emit(); + } + + onPasteLayer(): void { + this.pasteLayer.emit(); + } + onColorSelected(index): void { this.toggleColorFromPalette.emit(index); } diff --git a/packages/uhk-web/src/app/components/svg/wrap/svg-keyboard-wrap.component.html b/packages/uhk-web/src/app/components/svg/wrap/svg-keyboard-wrap.component.html index decace2d5ac..9424a9b138f 100644 --- a/packages/uhk-web/src/app/components/svg/wrap/svg-keyboard-wrap.component.html +++ b/packages/uhk-web/src/app/components/svg/wrap/svg-keyboard-wrap.component.html @@ -1,6 +1,8 @@ { this.keyPosition = this.keyElement.getBoundingClientRect(); diff --git a/packages/uhk-web/src/app/store/actions/keymap.ts b/packages/uhk-web/src/app/store/actions/keymap.ts index 5c599d4aaa6..05eeeb13177 100644 --- a/packages/uhk-web/src/app/store/actions/keymap.ts +++ b/packages/uhk-web/src/app/store/actions/keymap.ts @@ -9,6 +9,8 @@ import { ExchangeKeysActionModel, OpenPopoverModel } from '../../models'; export enum ActionTypes { Add = '[Keymap] Add keymap', AddLayer = '[Keymap] Add keymap layer', + CopyLayer = '[Keymap] Copy layer', + PasteLayer = '[Keymap] Paste layer', Duplicate = '[Keymap] Duplicate keymap', EditAbbr = '[Keymap] Edit keymap abbreviation', EditName = '[Keymap] Edit keymap title', @@ -41,6 +43,20 @@ export class AddLayerAction implements Action { } } +export class CopyLayerAction implements Action { + type = ActionTypes.CopyLayer; + + constructor(public payload: number) { + } +} + +export class PasteLayerAction implements Action { + type = ActionTypes.PasteLayer; + + constructor(public payload: number) { + } +} + export class DuplicateKeymapAction implements Action { type = ActionTypes.Duplicate; @@ -170,6 +186,8 @@ export class SelectLayerAction implements Action { export type Actions = AddKeymapAction | AddLayerAction + | CopyLayerAction + | PasteLayerAction | DuplicateKeymapAction | EditKeymapAbbreviationAction | EditKeymapNameAction diff --git a/packages/uhk-web/src/app/store/effects/user-config.ts b/packages/uhk-web/src/app/store/effects/user-config.ts index f5ccf860dc0..d8fd2f6f202 100644 --- a/packages/uhk-web/src/app/store/effects/user-config.ts +++ b/packages/uhk-web/src/app/store/effects/user-config.ts @@ -111,6 +111,7 @@ export class UserConfigEffects { Keymaps.ActionTypes.EditAbbr, Keymaps.ActionTypes.SetDefault, Keymaps.ActionTypes.Remove, Keymaps.ActionTypes.SaveKey, Keymaps.ActionTypes.EditDescription, Keymaps.ActionTypes.ExchangeKeys, Keymaps.ActionTypes.AddLayer, Keymaps.ActionTypes.RemoveLayer, Keymaps.ActionTypes.SetKeyColor, + Keymaps.ActionTypes.PasteLayer, Macros.ActionTypes.Add, Macros.ActionTypes.Duplicate, Macros.ActionTypes.EditName, Macros.ActionTypes.Remove, Macros.ActionTypes.AddAction, Macros.ActionTypes.SaveAction, Macros.ActionTypes.DeleteAction, Macros.ActionTypes.ReorderAction, Macros.ActionTypes.DuplicateAction, diff --git a/packages/uhk-web/src/app/store/index.ts b/packages/uhk-web/src/app/store/index.ts index 3487e9ef605..2628ea085be 100644 --- a/packages/uhk-web/src/app/store/index.ts +++ b/packages/uhk-web/src/app/store/index.ts @@ -139,6 +139,7 @@ export const getMacroMap = createSelector(userConfigState, fromUserConfig.getMac export const lastEditedKey = createSelector(userConfigState, fromUserConfig.lastEditedKey); export const getOpenPopover = createSelector(userConfigState, fromUserConfig.getOpenPopover); export const getSelectedLayerOption = createSelector(userConfigState, fromUserConfig.getSelectedLayerOption); +export const getHasCopiedLayer = createSelector(userConfigState, fromUserConfig.getHasCopiedLayer); export const getLayerOptions = createSelector(userConfigState, fromUserConfig.getLayerOptions); export const getSecondaryRoleOptions = createSelector(getSelectedLayerOption, getLayerOptions, (selectedLayer, layerOptions): SelectOptionData[] => { diff --git a/packages/uhk-web/src/app/store/reducers/user-configuration.ts b/packages/uhk-web/src/app/store/reducers/user-configuration.ts index da1cb72cab2..5d1381706a1 100644 --- a/packages/uhk-web/src/app/store/reducers/user-configuration.ts +++ b/packages/uhk-web/src/app/store/reducers/user-configuration.ts @@ -93,6 +93,8 @@ export interface State { theme: string; customAdvancedSecondaryRoleConfiguration?: AdvancedSecondaryRoleConfiguration; isCustomPresetTheLastLoadedPreset: boolean; + copiedLayer?: Layer; + copiedLayerMacroNames?: Map; } export const initialState: State = { @@ -382,6 +384,99 @@ export function reducer( return newState; } + case KeymapActions.ActionTypes.CopyLayer: { + const layerId = (action as KeymapActions.CopyLayerAction).payload; + const currentKeymap = state.userConfiguration.keymaps + .find(keymap => keymap.abbreviation === state.selectedKeymapAbbr); + const layerToCopy = currentKeymap?.layers.find(layer => layer.id === layerId); + + if (!layerToCopy) { + return state; + } + + // Snapshot the names of the macros referenced by the copied layer. Macro ids are + // config specific, so a later paste (possibly into another keymap or device) has to + // re-link them by name. + const copiedLayerMacroNames = new Map(); + for (const module of layerToCopy.modules) { + for (const keyAction of module.keyActions) { + if (keyAction instanceof PlayMacroAction && !copiedLayerMacroNames.has(keyAction.macroId)) { + const macro = state.userConfiguration.macros.find(m => m.id === keyAction.macroId); + if (macro) { + copiedLayerMacroNames.set(keyAction.macroId, macro.name); + } + } + } + } + + return { + ...state, + copiedLayer: new Layer(layerToCopy), + copiedLayerMacroNames, + }; + } + + case KeymapActions.ActionTypes.PasteLayer: { + if (!state.copiedLayer) { + return state; + } + + const targetLayerId = (action as KeymapActions.PasteLayerAction).payload; + const copiedLayer = state.copiedLayer; + const macroIdByName = new Map( + state.userConfiguration.macros.map(macro => [macro.name, macro.id]) + ); + const keymapAbbreviations = new Set(state.userConfiguration.keymaps.map(keymap => keymap.abbreviation)); + + const userConfiguration: UserConfiguration = Object.assign(new UserConfiguration(), state.userConfiguration); + userConfiguration.keymaps = userConfiguration.keymaps.map(keymap => { + if (keymap.abbreviation !== state.selectedKeymapAbbr) { + return keymap; + } + + keymap = new Keymap(keymap); + keymap.layers = keymap.layers.map(layer => { + if (layer.id !== targetLayerId) { + return layer; + } + + const pastedLayer = new Layer(); + pastedLayer.id = layer.id; + // Merge by module id + key count so pasting a layer that comes from a different + // keyboard or layout can never desync the target's module structure. Modules that + // have no compatible counterpart in the copied layer keep their current content. + pastedLayer.modules = layer.modules.map(targetModule => { + const sourceModule = copiedLayer.modules.find(module => + module.id === targetModule.id + && module.keyActions.length === targetModule.keyActions.length + ); + + if (!sourceModule) { + return new Module(targetModule); + } + + const pastedModule = new Module(targetModule); + pastedModule.keyActions = sourceModule.keyActions.map(keyAction => + remapCopiedKeyAction(keyAction, state.copiedLayerMacroNames, macroIdByName, keymapAbbreviations) + ); + + return pastedModule; + }); + + setSvgKeyboardCoverColorsOfLayer(userConfiguration.backlightingMode, pastedLayer, state.theme); + + return pastedLayer; + }); + + return keymap; + }); + + return { + ...state, + userConfiguration, + }; + } + case KeymapActions.ActionTypes.EditName: { const payload = (action as KeymapActions.EditKeymapNameAction).payload; @@ -1304,6 +1399,7 @@ export const getLayerOptions = (state: State): LayerOption[] => Array .from(state.layerOptions.values()) .sort((a, b) => a.order - b.order); export const getSelectedLayerOption = (state: State): LayerOption => state.selectedLayerOption; +export const getHasCopiedLayer = (state: State): boolean => !!state.copiedLayer; export const getSelectedMacroAction = (state: State): SelectedMacroAction => state.selectedMacroAction; export const getSelectedModuleConfiguration = (state: State): ModuleConfiguration => { if(!state.selectedModuleConfigurationId) { @@ -1550,6 +1646,53 @@ function assignUserConfiguration(state: State, userConfig: UserConfiguration, th } +/** + * Clones a key action coming from a copied layer and re-links or neutralizes its config specific + * references so that a paste can never introduce a dangling reference: + * - PlayMacroAction is re-linked to the target macro sharing the copied macro's name; if no such + * macro exists the key becomes a NoneAction (keeping its color). + * - SwitchKeymapAction is kept only if the referenced keymap abbreviation still exists, otherwise it + * becomes a NoneAction (keeping its color). + * SwitchLayerAction and keystroke secondary roles reference layers by fixed enum value, so they are + * safe to leave untouched even when the target keymap lacks that layer. + */ +function remapCopiedKeyAction( + keyAction: KeyAction, + macroNamesById: Map | undefined, + macroIdByName: Map, + keymapAbbreviations: Set, +): KeyAction { + const clonedKeyAction = KeyActionHelper.fromKeyAction(keyAction); + + if (clonedKeyAction instanceof PlayMacroAction) { + const macroName = macroNamesById?.get(clonedKeyAction.macroId); + const targetMacroId = macroName === undefined ? undefined : macroIdByName.get(macroName); + + if (targetMacroId === undefined) { + return toNoneActionWithColor(clonedKeyAction); + } + + clonedKeyAction.macroId = targetMacroId; + + return clonedKeyAction; + } + + if (clonedKeyAction instanceof SwitchKeymapAction && !keymapAbbreviations.has(clonedKeyAction.keymapAbbreviation)) { + return toNoneActionWithColor(clonedKeyAction); + } + + return clonedKeyAction; +} + +function toNoneActionWithColor(keyAction: KeyAction): NoneAction { + const noneAction = new NoneAction(); + noneAction.r = keyAction.r; + noneAction.g = keyAction.g; + noneAction.b = keyAction.b; + + return noneAction; +} + function saveKeyAction(userConfig: UserConfiguration, action: KeymapActions.SaveKeyAction): UserConfiguration { const payload = action.payload; const keyIndex: number = payload.key; diff --git a/packages/uhk-web/src/styles/_global.scss b/packages/uhk-web/src/styles/_global.scss index 59f7da8520c..a29e495b7f8 100644 --- a/packages/uhk-web/src/styles/_global.scss +++ b/packages/uhk-web/src/styles/_global.scss @@ -280,6 +280,11 @@ kbd { max-width: 350px; } +.tooltip-layer-action .tooltip-inner { + width: 320px; + max-width: 320px; +} + mwl-confirmation-popover-window { .popover { &.bottom { From c0fdc6c2fcf2eda84d4cbdff45328b0808240b40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kiss=20R=C3=B3bert?= Date: Fri, 10 Jul 2026 20:33:11 +0200 Subject: [PATCH 2/4] feat: copy/paste layer --- .../uhk-common/src/models/uhk-module-ids.ts | 9 + .../default-user-configuration.reducer.ts | 3 +- .../app/store/reducers/user-configuration.ts | 168 ++++++++++-------- packages/uhk-web/src/app/util/index.ts | 1 + ...-svg-keyboard-cover-colors-of-all-layer.ts | 12 +- ...-keyboard-cover-colors-of-keymap-layers.ts | 12 ++ 6 files changed, 120 insertions(+), 85 deletions(-) create mode 100644 packages/uhk-web/src/app/util/set-svg-keyboard-cover-colors-of-keymap-layers.ts diff --git a/packages/uhk-common/src/models/uhk-module-ids.ts b/packages/uhk-common/src/models/uhk-module-ids.ts index c648ec98bfe..c81905e3e08 100644 --- a/packages/uhk-common/src/models/uhk-module-ids.ts +++ b/packages/uhk-common/src/models/uhk-module-ids.ts @@ -10,3 +10,12 @@ export const UHK_MODULE_IDS = Object.freeze({ export type UHK_MODULE_IDS_KEY_TYPE = keyof typeof UHK_MODULE_IDS; export type UHK_MODULE_IDS_TYPE = typeof UHK_MODULE_IDS[UHK_MODULE_IDS_KEY_TYPE]; + +export const UHK_HALVES_AND_MODULE_IDS = [ + UHK_MODULE_IDS.RIGHT_HALF, + UHK_MODULE_IDS.LEFT_HALF, + UHK_MODULE_IDS.LEFT_KEY_CLUSTER, + UHK_MODULE_IDS.RIGHT_TRACKBALL, + UHK_MODULE_IDS.RIGHT_TRACKPOINT, + UHK_MODULE_IDS.RIGHT_TOUCHPAD, +] diff --git a/packages/uhk-web/src/app/store/reducers/default-user-configuration.reducer.ts b/packages/uhk-web/src/app/store/reducers/default-user-configuration.reducer.ts index 226eb957d14..0114435b229 100644 --- a/packages/uhk-web/src/app/store/reducers/default-user-configuration.reducer.ts +++ b/packages/uhk-web/src/app/store/reducers/default-user-configuration.reducer.ts @@ -26,7 +26,8 @@ export const initialState: State = { layerOptions: initLayerOptions(), loading: false, selectedLayerOption: getBaseLayerOption(), - theme: '' + // eslint-disable-next-line @typescript-eslint/no-explicit-any + theme: (window as any).getUhkTheme() }; export function reducer(state = initialState, action: Actions | AppActions.Actions) { diff --git a/packages/uhk-web/src/app/store/reducers/user-configuration.ts b/packages/uhk-web/src/app/store/reducers/user-configuration.ts index 5d1381706a1..e997b58f87b 100644 --- a/packages/uhk-web/src/app/store/reducers/user-configuration.ts +++ b/packages/uhk-web/src/app/store/reducers/user-configuration.ts @@ -40,6 +40,7 @@ import { SIMPLE_ADVANCED_SECONDARY_ROLE_CONFIGURATION_PRESET_NAME, SwitchKeymapAction, SwitchLayerAction, + UHK_HALVES_AND_MODULE_IDS, UserConfiguration } from 'uhk-common'; import { @@ -60,6 +61,7 @@ import { findModuleById, isValidName, setSvgKeyboardCoverColorsOfAllLayer, + setSvgKeyboardCoverColorsOfKeymapLayers, setSvgKeyboardCoverColorsOfLayer, } from '../../util'; import * as AppActions from '../actions/app'; @@ -108,7 +110,8 @@ export const initialState: State = { newPairedDevices: [], newPairedDevicesAdding: false, selectedLayerOption: getBaseLayerOption(), - theme: '', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + theme: (window as any).getUhkTheme(), isCustomPresetTheLastLoadedPreset: false, }; @@ -328,6 +331,7 @@ export function reducer( newKeymap.name = generateName(state.userConfiguration.keymaps, newKeymap.name); } newKeymap.isDefault = (state.userConfiguration.keymaps.length === 0); + setSvgKeyboardCoverColorsOfKeymapLayers(state.userConfiguration.backlightingMode, newKeymap, state.theme) const userConfiguration: UserConfiguration = Object.assign(new UserConfiguration(), state.userConfiguration); userConfiguration.keymaps = insertItemInNameOrder(state.userConfiguration.keymaps, newKeymap); @@ -388,11 +392,7 @@ export function reducer( const layerId = (action as KeymapActions.CopyLayerAction).payload; const currentKeymap = state.userConfiguration.keymaps .find(keymap => keymap.abbreviation === state.selectedKeymapAbbr); - const layerToCopy = currentKeymap?.layers.find(layer => layer.id === layerId); - - if (!layerToCopy) { - return state; - } + const layerToCopy = currentKeymap.layers.find(layer => layer.id === layerId); // Snapshot the names of the macros referenced by the copied layer. Macro ids are // config specific, so a later paste (possibly into another keymap or device) has to @@ -417,10 +417,6 @@ export function reducer( } case KeymapActions.ActionTypes.PasteLayer: { - if (!state.copiedLayer) { - return state; - } - const targetLayerId = (action as KeymapActions.PasteLayerAction).payload; const copiedLayer = state.copiedLayer; const macroIdByName = new Map( @@ -428,13 +424,19 @@ export function reducer( ); const keymapAbbreviations = new Set(state.userConfiguration.keymaps.map(keymap => keymap.abbreviation)); - const userConfiguration: UserConfiguration = Object.assign(new UserConfiguration(), state.userConfiguration); + let pasteToKeymap: Keymap; + let pasteToLayer: Layer; + + let userConfiguration: UserConfiguration = Object.assign(new UserConfiguration(), state.userConfiguration); + // 1. add module to the existing layer if it does not exist userConfiguration.keymaps = userConfiguration.keymaps.map(keymap => { if (keymap.abbreviation !== state.selectedKeymapAbbr) { return keymap; } keymap = new Keymap(keymap); + pasteToKeymap = keymap; + keymap.layers = keymap.layers.map(layer => { if (layer.id !== targetLayerId) { return layer; @@ -442,26 +444,21 @@ export function reducer( const pastedLayer = new Layer(); pastedLayer.id = layer.id; - // Merge by module id + key count so pasting a layer that comes from a different - // keyboard or layout can never desync the target's module structure. Modules that - // have no compatible counterpart in the copied layer keep their current content. - pastedLayer.modules = layer.modules.map(targetModule => { - const sourceModule = copiedLayer.modules.find(module => - module.id === targetModule.id - && module.keyActions.length === targetModule.keyActions.length - ); - - if (!sourceModule) { - return new Module(targetModule); - } + pastedLayer.modules = []; + pasteToLayer = pastedLayer; - const pastedModule = new Module(targetModule); - pastedModule.keyActions = sourceModule.keyActions.map(keyAction => - remapCopiedKeyAction(keyAction, state.copiedLayerMacroNames, macroIdByName, keymapAbbreviations) - ); + for (const moduleId of UHK_HALVES_AND_MODULE_IDS) { + const destinationModule = layer.modules.find(findModuleById(moduleId)); + const copiedModule = copiedLayer.modules.find(findModuleById(moduleId)); - return pastedModule; - }); + // the module neither on the destination or copied layer so we skip it + if (!destinationModule && !copiedModule) { + continue + } + + // the module exists either ont destination layer or copied layer + pastedLayer.modules.push(destinationModule || copiedModule); + } setSvgKeyboardCoverColorsOfLayer(userConfiguration.backlightingMode, pastedLayer, state.theme); @@ -471,6 +468,71 @@ export function reducer( return keymap; }); + // 2. we simulate Remap key events because we have so many business rule + // that don't want to duplicate. + + // The base layer drives the SwitchLayerActions + // if we copy to the base layer then the copied layer will the base layer + const baseLayer = targetLayerId === LayerName.base + ? copiedLayer + : pasteToKeymap.layers.find(layer => layer.id === LayerName.base) + + // - The SwitchLayerAction of Base layer has precedence + // - PlayMacroAction is re-linked to the target macro sharing the copied macro's name; if no such + // macro exists the key becomes a NoneAction (keeping its color). + // - SwitchKeymapAction is kept only + // - if the referenced keymap abbreviation still exists, otherwise it + // becomes a NoneAction (keeping its color). + // - if the referenced keymap is different from the pasted keymap + for (const copiedModule of copiedLayer.modules) { + const baseModule = baseLayer.modules.find(findModuleById(copiedModule.id)); + + for (let keyId = 0; keyId < copiedModule.keyActions.length; keyId++) { + const baseKey = baseModule?.keyActions[keyId]; + + let copiedKeyAction = copiedModule.keyActions?.[keyId]; + + if (baseKey instanceof SwitchLayerAction && pasteToLayer.id === baseKey.layer) { + copiedKeyAction = new SwitchLayerAction(baseKey); + } + else if (copiedKeyAction instanceof PlayMacroAction) { + const macroName = state.copiedLayerMacroNames.get(copiedKeyAction.macroId); + const macroId = macroIdByName.get(macroName); + + if (macroId === undefined) { + copiedKeyAction = new NoneAction(copiedKeyAction) + } + } + else if (copiedKeyAction instanceof SwitchKeymapAction) { + if (!keymapAbbreviations.has(copiedKeyAction.keymapAbbreviation) || copiedKeyAction.keymapAbbreviation === pasteToKeymap.abbreviation) { + copiedKeyAction = new NoneAction(copiedKeyAction) + } + } + else if (copiedKeyAction instanceof SwitchLayerAction && pasteToLayer.id !== LayerName.base) { + copiedKeyAction = new NoneAction(copiedKeyAction) + } + else if (copiedKeyAction instanceof KeystrokeAction && pasteToLayer.id !== LayerName.base) { + if (copiedKeyAction.secondaryRoleAction !== undefined) { + copiedKeyAction = new NoneAction(copiedKeyAction) + } + } + + const pasteToKeyAction = new KeymapActions.SaveKeyAction({ + keymap: pasteToKeymap, + layer: pasteToLayer.id, + module: copiedModule.id, + key: keyId, + keyAction: { + remapOnAllKeymap: false, + remapOnAllLayer: false, + action: copiedKeyAction + } + }); + + userConfiguration = saveKeyAction(userConfiguration, pasteToKeyAction) + } + } + return { ...state, userConfiguration, @@ -1646,53 +1708,6 @@ function assignUserConfiguration(state: State, userConfig: UserConfiguration, th } -/** - * Clones a key action coming from a copied layer and re-links or neutralizes its config specific - * references so that a paste can never introduce a dangling reference: - * - PlayMacroAction is re-linked to the target macro sharing the copied macro's name; if no such - * macro exists the key becomes a NoneAction (keeping its color). - * - SwitchKeymapAction is kept only if the referenced keymap abbreviation still exists, otherwise it - * becomes a NoneAction (keeping its color). - * SwitchLayerAction and keystroke secondary roles reference layers by fixed enum value, so they are - * safe to leave untouched even when the target keymap lacks that layer. - */ -function remapCopiedKeyAction( - keyAction: KeyAction, - macroNamesById: Map | undefined, - macroIdByName: Map, - keymapAbbreviations: Set, -): KeyAction { - const clonedKeyAction = KeyActionHelper.fromKeyAction(keyAction); - - if (clonedKeyAction instanceof PlayMacroAction) { - const macroName = macroNamesById?.get(clonedKeyAction.macroId); - const targetMacroId = macroName === undefined ? undefined : macroIdByName.get(macroName); - - if (targetMacroId === undefined) { - return toNoneActionWithColor(clonedKeyAction); - } - - clonedKeyAction.macroId = targetMacroId; - - return clonedKeyAction; - } - - if (clonedKeyAction instanceof SwitchKeymapAction && !keymapAbbreviations.has(clonedKeyAction.keymapAbbreviation)) { - return toNoneActionWithColor(clonedKeyAction); - } - - return clonedKeyAction; -} - -function toNoneActionWithColor(keyAction: KeyAction): NoneAction { - const noneAction = new NoneAction(); - noneAction.r = keyAction.r; - noneAction.g = keyAction.g; - noneAction.b = keyAction.b; - - return noneAction; -} - function saveKeyAction(userConfig: UserConfiguration, action: KeymapActions.SaveKeyAction): UserConfiguration { const payload = action.payload; const keyIndex: number = payload.key; @@ -1751,6 +1766,7 @@ function getKeyActionByExchangeKey(userConfig: UserConfiguration, exchangeKey: E } function reassignUserConfig(state: State): State { + // TODO: use UserConfiguration.clone() const userConfiguration = Object.assign(new UserConfiguration(), state.userConfiguration); userConfiguration.keymaps = userConfiguration.keymaps.map(keymap => new Keymap(keymap)); userConfiguration.macros = userConfiguration.macros.map(macro => new Macro(macro)); diff --git a/packages/uhk-web/src/app/util/index.ts b/packages/uhk-web/src/app/util/index.ts index 7c69497352e..2105cf05169 100644 --- a/packages/uhk-web/src/app/util/index.ts +++ b/packages/uhk-web/src/app/util/index.ts @@ -5,5 +5,6 @@ export * from './has-none-ascii-characters'; export * from './html-helper'; export * from './key-modifier-model-mapper'; export * from './set-svg-keyboard-cover-colors-of-all-layer'; +export * from './set-svg-keyboard-cover-colors-of-keymap-layers'; export * from './set-svg-keyboard-cover-colors-of-layer'; export * from './validators'; diff --git a/packages/uhk-web/src/app/util/set-svg-keyboard-cover-colors-of-all-layer.ts b/packages/uhk-web/src/app/util/set-svg-keyboard-cover-colors-of-all-layer.ts index 66ca63d89a5..ecc713d0bc2 100644 --- a/packages/uhk-web/src/app/util/set-svg-keyboard-cover-colors-of-all-layer.ts +++ b/packages/uhk-web/src/app/util/set-svg-keyboard-cover-colors-of-all-layer.ts @@ -1,14 +1,10 @@ -import { Keymap, UserConfiguration } from 'uhk-common'; +import { UserConfiguration } from 'uhk-common'; + +import { setSvgKeyboardCoverColorsOfKeymapLayers } from './set-svg-keyboard-cover-colors-of-keymap-layers'; -import { setSvgKeyboardCoverColorsOfLayer } from './set-svg-keyboard-cover-colors-of-layer'; export function setSvgKeyboardCoverColorsOfAllLayer(userConfig: UserConfiguration, theme: string): void { userConfig.keymaps = userConfig.keymaps.map(keymap => { - keymap = new Keymap(keymap); - for (const layer of keymap.layers) { - setSvgKeyboardCoverColorsOfLayer(userConfig.backlightingMode, layer, theme); - } - - return keymap; + return setSvgKeyboardCoverColorsOfKeymapLayers(userConfig.backlightingMode, keymap, theme); }); } diff --git a/packages/uhk-web/src/app/util/set-svg-keyboard-cover-colors-of-keymap-layers.ts b/packages/uhk-web/src/app/util/set-svg-keyboard-cover-colors-of-keymap-layers.ts new file mode 100644 index 00000000000..15d83c272be --- /dev/null +++ b/packages/uhk-web/src/app/util/set-svg-keyboard-cover-colors-of-keymap-layers.ts @@ -0,0 +1,12 @@ +import { BacklightingMode, Keymap } from 'uhk-common'; + +import { setSvgKeyboardCoverColorsOfLayer } from './set-svg-keyboard-cover-colors-of-layer'; + +export function setSvgKeyboardCoverColorsOfKeymapLayers(backligtingMode: BacklightingMode, keymap: Keymap, theme: string): Keymap { + keymap = new Keymap(keymap); + for (const layer of keymap.layers) { + setSvgKeyboardCoverColorsOfLayer(backligtingMode, layer, theme); + } + + return keymap +} From 0779a49b3d2a076f25007f49782bf1111f6fa28a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kiss=20R=C3=B3bert?= Date: Fri, 10 Jul 2026 21:30:21 +0200 Subject: [PATCH 3/4] fix: module paste ordering --- .../src/app/store/reducers/user-configuration.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/uhk-web/src/app/store/reducers/user-configuration.ts b/packages/uhk-web/src/app/store/reducers/user-configuration.ts index e997b58f87b..f66e5a5146d 100644 --- a/packages/uhk-web/src/app/store/reducers/user-configuration.ts +++ b/packages/uhk-web/src/app/store/reducers/user-configuration.ts @@ -451,13 +451,19 @@ export function reducer( const destinationModule = layer.modules.find(findModuleById(moduleId)); const copiedModule = copiedLayer.modules.find(findModuleById(moduleId)); - // the module neither on the destination or copied layer so we skip it + // the module neither on the destination nor copied layer so we skip it if (!destinationModule && !copiedModule) { continue } - // the module exists either ont destination layer or copied layer - pastedLayer.modules.push(destinationModule || copiedModule); + // the module exists only on the destination layer + if (destinationModule && !copiedModule) { + pastedLayer.modules.push(destinationModule); + } + + // use the copied module to ensure every key from it will copy + // for example when uhk 80 key copies to uhk 60 + pastedLayer.modules.push(copiedModule); } setSvgKeyboardCoverColorsOfLayer(userConfiguration.backlightingMode, pastedLayer, state.theme); From 860b5e660307d43fed996df488b33a036fb2a2e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kiss=20R=C3=B3bert?= Date: Fri, 10 Jul 2026 21:45:04 +0200 Subject: [PATCH 4/4] fix: copy swtich layer action only when destination layer exists --- .../src/app/store/reducers/user-configuration.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/uhk-web/src/app/store/reducers/user-configuration.ts b/packages/uhk-web/src/app/store/reducers/user-configuration.ts index f66e5a5146d..ae35b0a73e2 100644 --- a/packages/uhk-web/src/app/store/reducers/user-configuration.ts +++ b/packages/uhk-web/src/app/store/reducers/user-configuration.ts @@ -498,8 +498,14 @@ export function reducer( let copiedKeyAction = copiedModule.keyActions?.[keyId]; - if (baseKey instanceof SwitchLayerAction && pasteToLayer.id === baseKey.layer) { - copiedKeyAction = new SwitchLayerAction(baseKey); + if (baseKey instanceof SwitchLayerAction) { + const isLayerExists = pasteToKeymap.layers.some(layer => layer.id === baseKey.layer); + if (isLayerExists) { + copiedKeyAction = new SwitchLayerAction(baseKey); + } + else { + copiedKeyAction = new NoneAction(baseKey); + } } else if (copiedKeyAction instanceof PlayMacroAction) { const macroName = state.copiedLayerMacroNames.get(copiedKeyAction.macroId);