preload-menu.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. /** Windows caption menu labels and native popup anchors, isolated from the Web client. */
  2. import { ipcRenderer } from 'electron'
  3. import { DESKTOP_IPC } from './ipc.ts'
  4. import { resolveDesktopLocale } from './locale.ts'
  5. /**
  6. * Mount the Windows caption menubar without moving focus out of the active editor.
  7. * @returns Language refresh and document teardown operations.
  8. */
  9. export function installWindowsMenu(): { update(): void; dispose(): void } {
  10. const host = document.createElement('div')
  11. host.dataset.windowsMenu = ''
  12. const shadow = host.attachShadow({ mode: 'open' })
  13. const style = document.createElement('style')
  14. style.textContent = `
  15. :host { position: fixed; top: 0; left: var(--dsh-windows-menu-start, 48px); z-index: 1100;
  16. height: var(--dsh-windows-titlebar-height); display: flex; align-items: center;
  17. font-family: var(--dsw-font-family); -webkit-app-region: no-drag; }
  18. [role=menubar] { display: flex; gap: 2px; }
  19. button { height: 28px; padding: 0 10px; border: 0; border-radius: 6px;
  20. background: transparent; color: var(--dsw-alias-label-secondary);
  21. font: inherit; font-size: 14px; cursor: default; }
  22. button:hover, button[aria-expanded=true] { background: var(--dsw-alias-interactive-bg-hover);
  23. color: var(--dsw-alias-label-primary); }
  24. button:focus-visible { outline: 2px solid var(--dsw-alias-label-primary); outline-offset: -2px; }
  25. `
  26. const bar = document.createElement('div')
  27. bar.setAttribute('role', 'menubar')
  28. let restoreEditor = (): void => {}
  29. const rememberEditor = (event: FocusEvent): void => {
  30. const target = event.composedPath()[0]
  31. if (!(target instanceof HTMLElement) || target === host || shadow.contains(target)) return
  32. if (!(target instanceof HTMLInputElement) && !(target instanceof HTMLTextAreaElement)
  33. && !target.matches('[contenteditable="true"]')) return
  34. const selection = document.getSelection()
  35. const ranges = selection === null ? [] : Array.from({ length: selection.rangeCount }, (_, i) => selection.getRangeAt(i).cloneRange())
  36. const input = target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement ? target : undefined
  37. const start = input?.selectionStart
  38. const end = input?.selectionEnd
  39. const direction = input?.selectionDirection
  40. restoreEditor = () => {
  41. if (!target.isConnected) return
  42. target.focus({ preventScroll: true })
  43. if (input !== undefined && start != null && end != null) input.setSelectionRange(start, end, direction ?? undefined)
  44. else if (selection !== null && ranges.length > 0) {
  45. selection.removeAllRanges()
  46. for (const range of ranges) selection.addRange(range)
  47. }
  48. }
  49. }
  50. document.addEventListener('focusout', rememberEditor, true)
  51. const createButton = (name: 'application' | 'edit', index: 0 | 1): HTMLButtonElement => {
  52. const button = document.createElement('button')
  53. button.type = 'button'
  54. button.setAttribute('role', 'menuitem')
  55. button.setAttribute('aria-haspopup', 'menu')
  56. button.setAttribute('aria-expanded', 'false')
  57. button.tabIndex = index === 0 ? 0 : -1
  58. button.addEventListener('pointerdown', (event) => { event.preventDefault() })
  59. button.addEventListener('mousedown', (event) => { event.preventDefault() })
  60. const open = async (): Promise<void> => {
  61. if (button.getAttribute('aria-expanded') === 'true') return
  62. const rect = button.getBoundingClientRect()
  63. button.setAttribute('aria-expanded', 'true')
  64. if (document.activeElement === host) restoreEditor()
  65. try { await ipcRenderer.invoke(DESKTOP_IPC.windowsMenu, name, rect.left, rect.bottom) }
  66. catch (error) { console.error('Desktop caption menu failed', error) }
  67. finally { button.setAttribute('aria-expanded', 'false') }
  68. }
  69. button.addEventListener('click', () => { void open() })
  70. button.addEventListener('keydown', (event) => {
  71. if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
  72. event.preventDefault()
  73. const next = buttons[index === 0 ? 1 : 0]
  74. button.tabIndex = -1
  75. next.tabIndex = 0
  76. next.focus()
  77. } else if (event.key === 'ArrowDown') {
  78. event.preventDefault()
  79. void open()
  80. }
  81. })
  82. bar.append(button)
  83. return button
  84. }
  85. const buttons = [createButton('application', 0), createButton('edit', 1)] as const
  86. shadow.append(style, bar)
  87. const mount = (): void => {
  88. // AppFrame owns this seat; boot readiness alone precedes the rendered application.
  89. if (document.querySelector('[data-shell-overlay]') === null) return
  90. document.body.append(host)
  91. observer.disconnect()
  92. }
  93. const observer = new MutationObserver(mount)
  94. observer.observe(document.body, { childList: true, subtree: true })
  95. mount()
  96. const update = (): void => {
  97. const { messages } = resolveDesktopLocale(document.documentElement.lang)
  98. bar.setAttribute('aria-label', messages.menuBar)
  99. buttons[0].textContent = messages.application
  100. buttons[1].textContent = messages.edit
  101. }
  102. update()
  103. return {
  104. update,
  105. dispose: () => {
  106. observer.disconnect()
  107. document.removeEventListener('focusout', rememberEditor, true)
  108. host.remove()
  109. },
  110. }
  111. }