From 4a10a58a6e525b51c316d9ca39802ca2dea3e55c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kiss=20R=C3=B3bert?= Date: Sat, 10 Jan 2026 13:27:02 +0100 Subject: [PATCH 1/2] feat: save the SVG of the current layer --- packages/uhk-web/src/app/app.component.ts | 13 + .../slider/keyboard-slider.component.html | 2 + .../slider/keyboard-slider.component.ts | 1 + .../services/keyboard-svg-export.service.ts | 731 ++++++++++++++++++ 4 files changed, 747 insertions(+) create mode 100644 packages/uhk-web/src/app/services/keyboard-svg-export.service.ts diff --git a/packages/uhk-web/src/app/app.component.ts b/packages/uhk-web/src/app/app.component.ts index 1f2700389b3..f256b1211a3 100644 --- a/packages/uhk-web/src/app/app.component.ts +++ b/packages/uhk-web/src/app/app.component.ts @@ -9,6 +9,7 @@ import { Observable, Subscription } from 'rxjs'; import { Action, Store } from '@ngrx/store'; import { ERR_UPDATER_INVALID_SIGNATURE } from 'uhk-common'; +import { KeyboardSvgExportService } from './services/keyboard-svg-export.service'; import { ActionTypes as AppUpdateActionTypes } from './store/actions/app-update.action'; import { DoNotUpdateAppAction, UpdateAppAction } from './store/actions/app-update.action'; import { EnableUsbStackTestAction, UpdateFirmwareAction } from './store/actions/device'; @@ -144,6 +145,7 @@ export class MainAppComponent implements OnDestroy { private router: Router, private cdRef: ChangeDetectorRef, private actions$: Actions, + private keyboardSvgExportService: KeyboardSvgExportService, private notificationService: NotifierService, ) { this.actionsSubscription = actions$.pipe( @@ -247,6 +249,17 @@ export class MainAppComponent implements OnDestroy { @HostListener('document:keydown', ['$event']) onKeyDown(event: KeyboardEvent) { + // It should be before the CTRL + S to prevent the conflict with the SaveToKeyboard shortcut + if (event.ctrlKey && + event.altKey && + event.shiftKey && + event.code === 'KeyS' && + !event.defaultPrevented && + !this.keypressCapturing) { + this.keyboardSvgExportService.downloadSvgKeyboard(); + event.preventDefault(); + } + if (this.saveToKeyboardState.showButton && event.ctrlKey && event.key === 's' && diff --git a/packages/uhk-web/src/app/components/keyboard/slider/keyboard-slider.component.html b/packages/uhk-web/src/app/components/keyboard/slider/keyboard-slider.component.html index aa5d520861a..58e0c1d6a49 100644 --- a/packages/uhk-web/src/app/components/keyboard/slider/keyboard-slider.component.html +++ b/packages/uhk-web/src/app/components/keyboard/slider/keyboard-slider.component.html @@ -1,5 +1,6 @@ svg'); + + if (!svgKeyboard) { + this.logService.misc('[KeyboardSvgExportService] svg-keyboard element not found.'); + return; + } + + this.logService.misc('[KeyboardSvgExportService] clone svg-keyboard element.') + const clonedSvgKeyboard = svgKeyboard.cloneNode(true) as SVGElement; + + this.logService.misc('[KeyboardSvgExportService] apply inline style.'); + this.inlineStyles(svgKeyboard, clonedSvgKeyboard); + + this.logService.misc('[KeyboardSvgExportService] remove angular specific attributes.'); + this.removeAngularAttributes(clonedSvgKeyboard); + + this.logService.misc('[KeyboardSvgExportService] remove comments.'); + this.removeComments(clonedSvgKeyboard); + + this.logService.misc('[KeyboardSvgExportService] convert custom elements to svg group.'); + this.convertCustomElementsToGroups(clonedSvgKeyboard); + + this.logService.misc('[KeyboardSvgExportService] optimize styles.'); + this.optimizeStyles(clonedSvgKeyboard); + + this.logService.misc('[KeyboardSvgExportService] clean up inline styles.'); + this.cleanupInlineStyles(clonedSvgKeyboard); + + this.logService.misc('[KeyboardSvgExportService] clean up class attributes.'); + this.cleanupClassAttributes(clonedSvgKeyboard); + + this.logService.misc('[KeyboardSvgExportService] generate svg document.'); + const svgString = this.getSvgString(clonedSvgKeyboard); + + this.logService.misc('[KeyboardSvgExportService] download svg document.'); + this.downloadSvg(svgString); + this.logService.misc('[KeyboardSvgExportService] done.'); + } + + /** + * Inline all computed CSS styles from the original element to the cloned element + */ + private inlineStyles(originalElement: Element, clonedElement: Element): void { + const originalChildren = originalElement.children; + const clonedChildren = clonedElement.children; + + const computedStyle = window.getComputedStyle(originalElement); + + this.applyComputedStyles(clonedElement as HTMLElement | SVGElement, computedStyle); + + for (let i = 0; i < originalChildren.length; i++) { + this.inlineStyles(originalChildren[i], clonedChildren[i]); + } + } + + /** + * Apply computed styles to an element + */ + private applyComputedStyles(element: HTMLElement | SVGElement, computedStyle: CSSStyleDeclaration): void { + // List of CSS properties relevant to SVG rendering + const svgStyleProperties = [ + 'fill', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-dashoffset', + 'stroke-linecap', 'stroke-linejoin', 'stroke-miterlimit', 'stroke-opacity', + 'fill-opacity', 'opacity', 'font-family', 'font-size', 'font-weight', + 'font-style', 'text-anchor', 'dominant-baseline', 'alignment-baseline', + 'transform', 'transform-origin', 'display', 'visibility', 'clip-path', + 'mask', 'filter', 'marker-start', 'marker-mid', 'marker-end', + 'color', 'stop-color', 'stop-opacity', 'flood-color', 'flood-opacity', + 'lighting-color' + ]; + + let styleString = ''; + + for (const prop of svgStyleProperties) { + const value = computedStyle.getPropertyValue(prop); + + // Only add non-empty values that aren't default/initial + if (value && value !== 'none' && value !== 'normal' && value !== 'auto') { + styleString += `${prop}:${value};`; + } + } + + if (styleString) { + element.setAttribute('style', styleString); + } + } + + /** + * Optimize styles by extracting common style attributes to a