@brftech/filex-core 0.42.1 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/dist/ArchiveViewer-BqU761Wk.js +149 -0
- package/dist/ArchiveViewer-BqU761Wk.js.map +1 -0
- package/dist/CsvViewer-ByHVK5ga.js +172 -0
- package/dist/CsvViewer-ByHVK5ga.js.map +1 -0
- package/dist/{DrawioViewer-BuhZOAx6.js → DrawioViewer-Bgrj8-zG.js} +45 -44
- package/dist/DrawioViewer-Bgrj8-zG.js.map +1 -0
- package/dist/{EpubViewer-DS3mb3k8.js → EpubViewer-DJwUKlHW.js} +50 -50
- package/dist/EpubViewer-DJwUKlHW.js.map +1 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js +184 -0
- package/dist/IpynbViewer-Bz0eJ4NS.js.map +1 -0
- package/dist/MermaidViewer-CEDTD_pp.js +136 -0
- package/dist/MermaidViewer-CEDTD_pp.js.map +1 -0
- package/dist/PsdViewer-CZcxUpZO.js +121 -0
- package/dist/PsdViewer-CZcxUpZO.js.map +1 -0
- package/dist/TiffViewer-BwI62CbQ.js +141 -0
- package/dist/TiffViewer-BwI62CbQ.js.map +1 -0
- package/dist/{Viewer3D-CIrOn63I.js → Viewer3D-DoeZcuMm.js} +16 -16
- package/dist/Viewer3D-DoeZcuMm.js.map +1 -0
- package/dist/filex-core.js +475 -219
- package/dist/filex-core.umd.cjs +165 -108
- package/dist/filex-core.umd.cjs.map +1 -1
- package/dist/fonts/caveat-latin-ext.woff2 +0 -0
- package/dist/fonts/caveat-latin.woff2 +0 -0
- package/dist/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/dist/fonts/dancing-script-latin.woff2 +0 -0
- package/dist/fonts/homemade-apple-latin.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/dist/fonts/source-serif-4-latin.woff2 +0 -0
- package/dist/index-DuWnGuv5.js +35901 -0
- package/dist/index-DuWnGuv5.js.map +1 -0
- package/dist/index.d.ts +5241 -553
- package/dist/pdf-CaPYsOUo.js +21302 -0
- package/dist/pdf-CaPYsOUo.js.map +1 -0
- package/dist/style.css +1 -1
- package/package.json +1 -1
- package/src/FileExplorer.vue +1207 -343
- package/src/assets/fonts/LICENSE-Apache-2.0.txt +202 -0
- package/src/assets/fonts/LICENSE-OFL-1.1.txt +93 -0
- package/src/assets/fonts/README.md +58 -0
- package/src/assets/fonts/caveat-latin-ext.woff2 +0 -0
- package/src/assets/fonts/caveat-latin.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin-ext.woff2 +0 -0
- package/src/assets/fonts/dancing-script-latin.woff2 +0 -0
- package/src/assets/fonts/homemade-apple-latin.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin-ext.woff2 +0 -0
- package/src/assets/fonts/source-serif-4-latin.woff2 +0 -0
- package/src/components/AdvancedSearch.vue +49 -0
- package/src/components/Breadcrumb.vue +5 -5
- package/src/components/ChoiceButtons.vue +152 -0
- package/src/components/CommandPalette.vue +5 -5
- package/src/components/ConnectionGuideView.vue +1 -1
- package/src/components/ConnectionNotice.vue +59 -0
- package/src/components/ConnectionsPanel.vue +30 -568
- package/src/components/ContextMenu.vue +31 -5
- package/src/components/DataTable.vue +1331 -0
- package/src/components/FilePane.vue +3 -0
- package/src/components/FilterBar.vue +12 -5
- package/src/components/GalleryView.vue +50 -2
- package/src/components/GridView.vue +51 -3
- package/src/components/HomeView.vue +18 -6
- package/src/components/InspectorPanel.vue +134 -17
- package/src/components/ListView.vue +366 -900
- package/src/components/LogoMark.vue +57 -0
- package/src/components/NFSExportsPanel.vue +76 -28
- package/src/components/OnboardingTour.vue +19 -7
- package/src/components/OperationsCenter.vue +77 -1
- package/src/components/PendingOpsTray.vue +59 -5
- package/src/components/PresenceBar.vue +7 -7
- package/src/components/ProductVersion.vue +31 -0
- package/src/components/QuickLook.vue +10 -14
- package/src/components/RecentlyOpened.vue +47 -17
- package/src/components/RecoveryKeyModal.vue +1 -1
- package/src/components/RowActions.vue +134 -0
- package/src/components/S3KeysPanel.vue +98 -56
- package/src/components/SSHKeysPanel.vue +79 -26
- package/src/components/SideNav.vue +260 -96
- package/src/components/StarButton.vue +1 -0
- package/src/components/StorageFields.vue +74 -6
- package/src/components/StorageTags.vue +62 -0
- package/src/components/TabBar.vue +1 -1
- package/src/components/TagKindIcon.vue +56 -0
- package/src/components/TagPicker.vue +226 -40
- package/src/components/ThemePalette.vue +4 -4
- package/src/components/TimeZonePicker.vue +29 -10
- package/src/components/TokensPanel.vue +82 -27
- package/src/components/Toolbar.vue +28 -6
- package/src/components/plugin/PluginConfirmModal.vue +54 -0
- package/src/components/plugin/PluginInspectorSection.vue +149 -0
- package/src/components/plugin/PluginPageView.vue +339 -0
- package/src/components/plugin/PluginViewModal.vue +159 -0
- package/src/components/plugin/SurfaceConversation.vue +76 -0
- package/src/components/plugin/SurfaceFooterButtons.vue +73 -0
- package/src/components/plugin/SurfaceRenderer.vue +299 -0
- package/src/components/plugin/SurfaceSections.vue +91 -0
- package/src/components/plugin/nodes/PdfFieldEditor.vue +509 -0
- package/src/components/plugin/nodes/SurfaceFileChooser.vue +94 -0
- package/src/components/plugin/nodes/SurfaceForm.vue +82 -0
- package/src/components/plugin/nodes/SurfaceList.vue +127 -0
- package/src/components/plugin/nodes/SurfacePdfFields.vue +1285 -0
- package/src/components/plugin/nodes/SurfacePeoplePicker.vue +213 -0
- package/src/components/plugin/nodes/SurfacePinInput.vue +57 -0
- package/src/components/plugin/nodes/SurfacePreview.vue +119 -0
- package/src/components/plugin/nodes/SurfaceProgress.vue +48 -0
- package/src/components/plugin/nodes/SurfaceSignaturePad.vue +472 -0
- package/src/components/plugin/nodes/SurfaceSteps.vue +53 -0
- package/src/components/public/PublicLanguagePicker.vue +58 -0
- package/src/components/public/PublicLinkPage.vue +286 -0
- package/src/components/public/PublicLinkPreview.vue +85 -0
- package/src/components/public/PublicPinGate.vue +108 -0
- package/src/components/public/PublicRequestBody.vue +204 -0
- package/src/components/public/PublicShareBody.vue +188 -0
- package/src/components/public/PublicShell.vue +332 -0
- package/src/composables/useConnections.ts +8 -2
- package/src/composables/useFileApi.ts +183 -44
- package/src/composables/useLocale.ts +277 -41
- package/src/composables/useNFSExports.ts +8 -10
- package/src/composables/useOperations.ts +44 -3
- package/src/composables/usePendingOps.ts +59 -8
- package/src/composables/usePluginActions.ts +121 -0
- package/src/composables/usePluginSurface.ts +223 -0
- package/src/composables/usePublicBranding.ts +172 -0
- package/src/composables/usePublicLink.ts +573 -0
- package/src/composables/usePublicPage.ts +61 -0
- package/src/composables/useS3Keys.ts +17 -12
- package/src/composables/useSSHKeys.ts +8 -10
- package/src/composables/useTokens.ts +8 -12
- package/src/composables/useUploadChunked.ts +18 -8
- package/src/composables/useViewerFetch.ts +6 -3
- package/src/index.ts +404 -8
- package/src/lib/actionIcons.ts +46 -1
- package/src/lib/advSearch.ts +14 -7
- package/src/lib/appLock.ts +130 -0
- package/src/lib/connection.ts +110 -0
- package/src/lib/destinationTree.ts +19 -3
- package/src/lib/direction.ts +353 -0
- package/src/lib/dragGhost.ts +6 -0
- package/src/lib/dropLimits.ts +69 -0
- package/src/lib/errorWords.ts +394 -0
- package/src/lib/externalReach.ts +5 -1
- package/src/lib/fileFilters.ts +7 -2
- package/src/lib/fileIcons.ts +4 -0
- package/src/lib/internalPaths.ts +77 -0
- package/src/lib/listInput.ts +64 -0
- package/src/lib/listing.ts +58 -23
- package/src/lib/modalStack.ts +26 -0
- package/src/lib/nodeRow.ts +121 -0
- package/src/lib/pdfFieldRules.ts +126 -0
- package/src/lib/pdfFields.ts +589 -0
- package/src/lib/pdfFieldsGeom.ts +203 -0
- package/src/lib/pdfjsLoader.ts +51 -0
- package/src/lib/personName.ts +47 -0
- package/src/lib/pluginApplies.ts +123 -0
- package/src/lib/pluginLabel.ts +72 -0
- package/src/lib/pluginMenu.ts +253 -0
- package/src/lib/pluginPage.ts +58 -0
- package/src/lib/plural.ts +181 -0
- package/src/lib/popupLayer.ts +103 -0
- package/src/lib/prefs.ts +563 -0
- package/src/lib/productVersion.ts +35 -0
- package/src/lib/publicLayout.ts +39 -0
- package/src/lib/serviceGate.ts +104 -0
- package/src/lib/shareTtl.ts +25 -11
- package/src/lib/signFonts.ts +85 -0
- package/src/lib/signaturePad.ts +111 -0
- package/src/lib/sortOrder.ts +100 -38
- package/src/lib/star.ts +7 -1
- package/src/lib/surfaceCell.ts +41 -0
- package/src/lib/surfaceConditions.ts +156 -0
- package/src/lib/surfaceOpen.ts +106 -0
- package/src/lib/surfaceSteps.ts +68 -0
- package/src/lib/surfaceValues.ts +191 -0
- package/src/lib/symlink.ts +150 -0
- package/src/lib/tableColumns.ts +493 -0
- package/src/lib/tableEnv.ts +34 -0
- package/src/lib/tablePrefs.ts +84 -0
- package/src/lib/tags.ts +121 -12
- package/src/lib/themes.ts +286 -18
- package/src/lib/tour.ts +152 -0
- package/src/lib/trashEmpty.ts +111 -0
- package/src/lib/trashTimeLeft.ts +23 -0
- package/src/lib/uiLocales.ts +460 -0
- package/src/lib/viewPrefs.ts +696 -660
- package/src/lib/viewPrefsHttp.ts +78 -0
- package/src/locales/en.ts +522 -119
- package/src/locales/resolve.ts +17 -8
- package/src/locales/tr.ts +539 -155
- package/src/modals/ConvertModal.vue +62 -11
- package/src/modals/DestinationPickerModal.vue +59 -16
- package/src/modals/Modal.vue +113 -23
- package/src/modals/NewDocumentModal.vue +16 -6
- package/src/modals/NewFolderModal.vue +8 -0
- package/src/modals/PermissionsModal.vue +272 -163
- package/src/modals/PreviewModal.vue +119 -20
- package/src/modals/RenameModal.vue +37 -1
- package/src/styles/base.css +2895 -241
- package/src/styles/sign-fonts.css +118 -0
- package/src/styles/variables.css +60 -0
- package/src/types/Connections.ts +17 -0
- package/src/types/ExplorerConfig.ts +117 -1
- package/src/types/FileNode.ts +58 -0
- package/src/types/Plugins.ts +494 -0
- package/src/types/Public.ts +212 -0
- package/src/viewers/ArchiveViewer.vue +93 -48
- package/src/viewers/CsvViewer.vue +99 -53
- package/src/viewers/DrawioViewer.vue +35 -9
- package/src/viewers/EpubViewer.vue +7 -8
- package/src/viewers/IpynbViewer.vue +3 -1
- package/src/viewers/MermaidViewer.vue +4 -5
- package/src/viewers/PdfViewer.vue +8 -17
- package/src/viewers/PsdViewer.vue +2 -4
- package/src/viewers/TiffViewer.vue +4 -5
- package/src/viewers/Viewer3D.vue +1 -1
- package/LICENSE +0 -21
- package/dist/ArchiveViewer-CLNevfPE.js +0 -124
- package/dist/ArchiveViewer-CLNevfPE.js.map +0 -1
- package/dist/CsvViewer-K8XA3z7N.js +0 -141
- package/dist/CsvViewer-K8XA3z7N.js.map +0 -1
- package/dist/DrawioViewer-BuhZOAx6.js.map +0 -1
- package/dist/EpubViewer-DS3mb3k8.js.map +0 -1
- package/dist/IpynbViewer-BTk7p4Dn.js +0 -184
- package/dist/IpynbViewer-BTk7p4Dn.js.map +0 -1
- package/dist/MermaidViewer-DqucH_io.js +0 -137
- package/dist/MermaidViewer-DqucH_io.js.map +0 -1
- package/dist/PsdViewer-CwiVENmq.js +0 -122
- package/dist/PsdViewer-CwiVENmq.js.map +0 -1
- package/dist/TiffViewer-CpewSVmQ.js +0 -142
- package/dist/TiffViewer-CpewSVmQ.js.map +0 -1
- package/dist/Viewer3D-CIrOn63I.js.map +0 -1
- package/dist/index-Dx0IbBcI.js +0 -26831
- package/dist/index-Dx0IbBcI.js.map +0 -1
- package/dist/useViewerFetch-czqbd2Lj.js +0 -25
- package/dist/useViewerFetch-czqbd2Lj.js.map +0 -1
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pluginMenu — the file menu's plugin rows, as pure data.
|
|
3
|
+
*
|
|
4
|
+
* `FileExplorer.selectionActionList` splices these between the tags row and
|
|
5
|
+
* the desktop-sync rows. Kept out of the component so the gating can be
|
|
6
|
+
* tested without mounting the explorer: a row is offered only when the
|
|
7
|
+
* feature is on, the selection is a plain listing selection (not the trash,
|
|
8
|
+
* not inside an encrypted folder, not a storage mount row) and the action's
|
|
9
|
+
* `applies` rule accepts it.
|
|
10
|
+
*
|
|
11
|
+
* ⚠ v2: the rule reads the row's `app_state` too, so one app contributes
|
|
12
|
+
* DIFFERENT rows to the same menu depending on the file — "Sign / Fill"
|
|
13
|
+
* where a signature is pending, "Request signatures" where none is. They
|
|
14
|
+
* are ordinary rows beside the built-in verbs; nothing about them is a
|
|
15
|
+
* separate menu or a separate surface.
|
|
16
|
+
*/
|
|
17
|
+
import type { PluginActionRow, PluginGatedRule } from '../types/Plugins';
|
|
18
|
+
import { appliesToNodes, type AppliesNodeLike } from './pluginApplies';
|
|
19
|
+
import { labelOf } from './pluginLabel';
|
|
20
|
+
|
|
21
|
+
/** The `ContextAction` fields a plugin row fills (the menu's own type owns the rest). */
|
|
22
|
+
export interface PluginMenuRow {
|
|
23
|
+
key: string;
|
|
24
|
+
label: string;
|
|
25
|
+
icon?: string;
|
|
26
|
+
danger?: boolean;
|
|
27
|
+
divider?: boolean;
|
|
28
|
+
/** Greyed: offered once something the server lacks is there (`title` says what). */
|
|
29
|
+
disabled?: boolean;
|
|
30
|
+
title?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface PluginMenuContext {
|
|
34
|
+
locale: string;
|
|
35
|
+
/** The trash view is open — plugins never run on trashed rows. */
|
|
36
|
+
trash?: boolean;
|
|
37
|
+
/** Inside an E2E-encrypted folder — the server would only see ciphertext. */
|
|
38
|
+
e2e?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* The selection sits on a read-only storage. An action whose result is
|
|
41
|
+
* WRITTEN there (`output_mode` `sibling` / `version`) is then not offered —
|
|
42
|
+
* see `pluginActionWrites`.
|
|
43
|
+
*/
|
|
44
|
+
readOnly?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* The caller's level on one selected row (`none` | `viewer` | `editor` |
|
|
47
|
+
* `owner`), as the explorer's own write gate reads it (`rowPerm`). An
|
|
48
|
+
* action the level cannot run is not offered — see `pluginActionNeed`.
|
|
49
|
+
* Omitted, or any other answer (`''`, undefined: no ACL on that storage),
|
|
50
|
+
* leaves the row ungated; the server enforces either way.
|
|
51
|
+
*/
|
|
52
|
+
permOf?: (node: PluginMenuNode) => string | undefined;
|
|
53
|
+
/** Which manifest icon names have a glyph; others fall back to `plugin`. */
|
|
54
|
+
hasIcon?: (name: string) => boolean;
|
|
55
|
+
/**
|
|
56
|
+
* The sentence for a greyed row (an action's `gated` rule): what the server
|
|
57
|
+
* lacks, in the reader's language. Absent, gated rows are not drawn.
|
|
58
|
+
*/
|
|
59
|
+
needWords?: (need: PluginGatedRule['needs']) => string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What the menu reads of a selected row. */
|
|
63
|
+
export type PluginMenuNode = AppliesNodeLike & {
|
|
64
|
+
e2e?: boolean;
|
|
65
|
+
mime_type?: string | null;
|
|
66
|
+
/** The app holding the file read-only, if one does (listing `lock`). */
|
|
67
|
+
lock?: { plugin?: string } | null;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The Convert app's manifest name (BRF-Tech/filex-convert). Its actions
|
|
72
|
+
* replace the legacy iframe converter — see serviceGate `legacyConvertGate`.
|
|
73
|
+
*/
|
|
74
|
+
export const CONVERT_APP = 'convert';
|
|
75
|
+
|
|
76
|
+
/** Whether the Convert app has any action on this instance for this caller. */
|
|
77
|
+
export function convertAppOffered(actions: readonly Pick<PluginActionRow, 'plugin'>[]): boolean {
|
|
78
|
+
return actions.some((a) => a.plugin === CONVERT_APP);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The menu key for an action row: the server's, or `plugin:<plugin>/<id>`. */
|
|
82
|
+
export function pluginActionKey(a: Pick<PluginActionRow, 'plugin' | 'id' | 'key'>): string {
|
|
83
|
+
return a.key || `plugin:${a.plugin}/${a.id}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether a menu key names a plugin action. */
|
|
87
|
+
export function isPluginActionKey(key: string): boolean {
|
|
88
|
+
return key.startsWith('plugin:');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Whether running the action writes next to or over its file: its manifest
|
|
93
|
+
* output is `sibling` (a new file beside it) or `version` (a new version of
|
|
94
|
+
* it). The server's own test is the same one (handlers/app_plugins.go
|
|
95
|
+
* `authorise` → `jobOutputMode`), and it refuses those on a read-only storage
|
|
96
|
+
* with `409 read_only`.
|
|
97
|
+
*
|
|
98
|
+
* ⚠⚠ Why the menu asks at all (QA, 2026-09-21): on a read-only drive
|
|
99
|
+
* "Dönüştür…", "İmzala…" and "İmza iste…" were all offered, and the first
|
|
100
|
+
* one answered the click with a 409 and a toast that was gone in 2.5 s — for
|
|
101
|
+
* the person it did nothing. An action that can only be refused is not
|
|
102
|
+
* offered, the same rule the built-in verbs follow (Rename / Delete / Move
|
|
103
|
+
* are not in the menu of a read-only row either).
|
|
104
|
+
*
|
|
105
|
+
* ⚠ `none` answers false, and that is the limit of what the menu can know:
|
|
106
|
+
* an action with no file output may still write through the app's own
|
|
107
|
+
* permissions ("İmza iste…" starts a flow whose LAST step writes the signed
|
|
108
|
+
* copy). The app has to refuse that itself, in words, and the host's refusal
|
|
109
|
+
* path (useFileApi `refusalMessage`) says a `read_only` answer in words
|
|
110
|
+
* whichever surface it comes back through.
|
|
111
|
+
*/
|
|
112
|
+
export function pluginActionWrites(a: Pick<PluginActionRow, 'output_mode'> & { applies?: Pick<PluginActionRow['applies'], 'writable'> }): boolean {
|
|
113
|
+
return a.output_mode === 'sibling' || a.output_mode === 'version' || a.applies?.writable === true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Not offered on a read-only storage: an action that writes there — unless
|
|
118
|
+
* its result may go into a folder the person chooses (`output_elsewhere`),
|
|
119
|
+
* whose screen then asks where.
|
|
120
|
+
*/
|
|
121
|
+
function refusedReadOnly(a: PluginActionRow): boolean {
|
|
122
|
+
return pluginActionWrites(a) && a.output_elsewhere !== true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const LEVEL_RANK: Record<string, number> = { none: 0, viewer: 1, editor: 2, owner: 3 };
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The level a caller needs on every input to run the action — the server's
|
|
129
|
+
* `pluginACLNeed` (handlers/app_plugins.go), mirrored: `viewer` to read,
|
|
130
|
+
* `editor` once the job writes its result back, and whatever higher floor the
|
|
131
|
+
* manifest's `min_role` declares.
|
|
132
|
+
*
|
|
133
|
+
* ⚠⚠ Why the menu asks (v0.43.0 wave 2, 2026-09-22): the menu never read
|
|
134
|
+
* `min_role` at all. A person granted `viewer` on an RBAC storage was offered
|
|
135
|
+
* "Dönüştür…", "İmzala…" and "İmza iste…" on every file there, and each one
|
|
136
|
+
* answered the click with `403 insufficient permission` — the same "offered,
|
|
137
|
+
* then refused" the read-only rule above already removed. The built-in verbs
|
|
138
|
+
* follow the level (Rename / Delete are not in a viewer's menu); the app's
|
|
139
|
+
* rows now do too.
|
|
140
|
+
*/
|
|
141
|
+
export function pluginActionNeed(a: Pick<PluginActionRow, 'output_mode' | 'min_role'>): 'viewer' | 'editor' | 'owner' {
|
|
142
|
+
if (a.min_role === 'owner') return 'owner';
|
|
143
|
+
if (a.min_role === 'editor' || pluginActionWrites(a)) return 'editor';
|
|
144
|
+
return 'viewer';
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Whether the caller's level on `node` lets them run `a`.
|
|
149
|
+
*
|
|
150
|
+
* ⚠ A row the SAME app has locked is not judged by its listed level: the
|
|
151
|
+
* listing caps a locked file at `viewer` for everyone (app_plugins_badges.go),
|
|
152
|
+
* which is the lock speaking, not the person's grant — and the app holding it
|
|
153
|
+
* is exactly the one that must still reach it ("Sign / Fill" on a document
|
|
154
|
+
* frozen while signatures are collected). The server decides that row.
|
|
155
|
+
*/
|
|
156
|
+
function levelAllows(a: PluginActionRow, node: PluginMenuNode, permOf: (n: PluginMenuNode) => string | undefined): boolean {
|
|
157
|
+
if (node.lock?.plugin && node.lock.plugin === a.plugin) return true;
|
|
158
|
+
const level = permOf(node);
|
|
159
|
+
if (level === undefined || !(level in LEVEL_RANK)) return true;
|
|
160
|
+
return LEVEL_RANK[level] >= LEVEL_RANK[pluginActionNeed(a)];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The action rows whose rule accepts this selection. */
|
|
164
|
+
export function pluginActionsFor(
|
|
165
|
+
actions: readonly PluginActionRow[],
|
|
166
|
+
selection: readonly AppliesNodeLike[],
|
|
167
|
+
): PluginActionRow[] {
|
|
168
|
+
if (selection.length === 0) return [];
|
|
169
|
+
// ⚠ `a.plugin` is not decoration: `applies.state` names the plugin's OWN
|
|
170
|
+
// keys while the rows carry them namespaced, so the matcher has to know
|
|
171
|
+
// whose keys to look for (lib/pluginApplies → stateKeysOf).
|
|
172
|
+
return actions.filter((a) => appliesToNodes(a.applies, selection as AppliesNodeLike[], a.plugin));
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Menu rows for a selection, or `[]`.
|
|
177
|
+
*
|
|
178
|
+
* ONE GROUP PER APP, each behind its own divider. The first divider keeps its
|
|
179
|
+
* old key, `sep-plugins`, so the block still reads as its own group under
|
|
180
|
+
* Tags; every further app opens with `sep-plugin:<plugin>`.
|
|
181
|
+
*
|
|
182
|
+
* ⚠⚠ Why per app and not one block. The owner, 2026-09-21, looking at a PDF's
|
|
183
|
+
* right-click menu with the converter and the signing app both installed:
|
|
184
|
+
* "dönüştür ve imzalama pluginlerinin menüleri — yani her plugin'in menüleri —
|
|
185
|
+
* arasına çizgi çekelim". Eight rows from two apps in one undivided run read
|
|
186
|
+
* as one app's vocabulary, and "Convert to DOCX" sitting directly above
|
|
187
|
+
* "Request signatures" invited the question of which app would do what. The
|
|
188
|
+
* divider is the menu's own mechanism (`ContextAction.divider`), so the right
|
|
189
|
+
* click, the selection bar's "⋯" and the ⋮ all draw the same lines, and
|
|
190
|
+
* `ContextMenu` still collapses a divider that ends up leading, trailing or
|
|
191
|
+
* doubled.
|
|
192
|
+
*
|
|
193
|
+
* ⚠ Grouped by `plugin`, in the order each app's FIRST row arrived — not
|
|
194
|
+
* alphabetically and not by re-sorting the rows. The server lists an app's
|
|
195
|
+
* actions in manifest order and the app author chose that order; a stable
|
|
196
|
+
* grouping keeps it inside each app and only pulls apart rows that were
|
|
197
|
+
* interleaved (which the server does not do today, but a grouping that
|
|
198
|
+
* depended on it would break silently the day it did).
|
|
199
|
+
*/
|
|
200
|
+
export function pluginMenuRows(
|
|
201
|
+
actions: readonly PluginActionRow[],
|
|
202
|
+
selection: readonly PluginMenuNode[],
|
|
203
|
+
ctx: PluginMenuContext,
|
|
204
|
+
): PluginMenuRow[] {
|
|
205
|
+
if (ctx.trash || ctx.e2e) return [];
|
|
206
|
+
if (selection.some((n) => n.e2e === true || n.mime_type === 'inode/storage')) return [];
|
|
207
|
+
const permOf = ctx.permOf;
|
|
208
|
+
const allowed = (a: PluginActionRow) =>
|
|
209
|
+
!(ctx.readOnly && refusedReadOnly(a)) && (!permOf || selection.every((n) => levelAllows(a, n, permOf)));
|
|
210
|
+
const offered = pluginActionsFor(actions, selection).filter(allowed);
|
|
211
|
+
// ⚠ Greyed WITH the reason — the owner's rule for anything that depends on
|
|
212
|
+
// how the server is set up ("not configured → disabled with the reason for
|
|
213
|
+
// administrators, hidden for everybody else"). The server sends `gated`
|
|
214
|
+
// to administrators only; here a row the selection would take once the
|
|
215
|
+
// missing piece is installed is drawn disabled, saying what is missing
|
|
216
|
+
// ("LibreOffice is not installed on this server").
|
|
217
|
+
const greyed = new Map<PluginActionRow, string>();
|
|
218
|
+
if (ctx.needWords) {
|
|
219
|
+
for (const a of actions) {
|
|
220
|
+
if (offered.includes(a) || !a.gated?.length || !allowed(a)) continue;
|
|
221
|
+
const gate = a.gated.find((g) =>
|
|
222
|
+
appliesToNodes({ ...a.applies, ext: g.ext ?? [], mime: [] }, selection as AppliesNodeLike[], a.plugin),
|
|
223
|
+
);
|
|
224
|
+
if (gate) greyed.set(a, ctx.needWords(gate.needs));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const rows = actions.filter((a) => offered.includes(a) || greyed.has(a));
|
|
228
|
+
if (rows.length === 0) return [];
|
|
229
|
+
const hasIcon = ctx.hasIcon ?? (() => false);
|
|
230
|
+
const byApp = new Map<string, PluginActionRow[]>();
|
|
231
|
+
for (const a of rows) {
|
|
232
|
+
const list = byApp.get(a.plugin);
|
|
233
|
+
if (list) list.push(a);
|
|
234
|
+
else byApp.set(a.plugin, [a]);
|
|
235
|
+
}
|
|
236
|
+
const out: PluginMenuRow[] = [];
|
|
237
|
+
let first = true;
|
|
238
|
+
for (const [plugin, list] of byApp) {
|
|
239
|
+
out.push({ divider: true, key: first ? 'sep-plugins' : `sep-plugin:${plugin}`, label: '' });
|
|
240
|
+
first = false;
|
|
241
|
+
for (const a of list) {
|
|
242
|
+
const why = greyed.get(a);
|
|
243
|
+
out.push({
|
|
244
|
+
key: pluginActionKey(a),
|
|
245
|
+
label: labelOf(a.label, ctx.locale),
|
|
246
|
+
icon: a.icon && hasIcon(a.icon) ? a.icon : 'plugin',
|
|
247
|
+
danger: a.danger === true,
|
|
248
|
+
...(why ? { disabled: true, title: why } : {}),
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pluginPage — where an app plugin's `page` view lives, and how it is opened.
|
|
3
|
+
*
|
|
4
|
+
* A `modal` view is a dialog over the listing. A `page` view is the SAME
|
|
5
|
+
* conversation (same `GET …/views/{p}/{v}?path=`, same `POST …/event`) drawn
|
|
6
|
+
* as a whole page in a NEW TAB — for a wizard that needs the document beside
|
|
7
|
+
* it, where a dialog's inner scroll would cramp both. The action row says
|
|
8
|
+
* which (`view_placement`), so nothing about the plugin changes between the
|
|
9
|
+
* two; only the frame does.
|
|
10
|
+
*
|
|
11
|
+
* ⚠⚠ This lives in the package, not in the admin SPA, because the rule is the
|
|
12
|
+
* same everywhere the explorer runs — fm.example.com, the desktop shell, the
|
|
13
|
+
* work.example.com and fishapp embeds. What differs per host is only WHERE the
|
|
14
|
+
* page is served and HOW a new surface is opened, and those are the two
|
|
15
|
+
* things `ExplorerConfig` asks the host for (`pluginPageBase`,
|
|
16
|
+
* `openPluginPage`). A host that wires neither keeps the modal: a `page`
|
|
17
|
+
* action still works, it just opens in a dialog rather than a tab. That is a
|
|
18
|
+
* degraded frame, never a missing feature.
|
|
19
|
+
*
|
|
20
|
+
* ⚠ The address in the contract (`/apps/{plugin}/{view}?path=…`) is relative
|
|
21
|
+
* to the SPA's MOUNT BASE, not to the site root: the same bundle is served
|
|
22
|
+
* from `/admin/` and `/drive/` (backend routes.go → wireStatic), and
|
|
23
|
+
* only those prefixes fall back to index.html. A bare `/apps/…` is a 404 on
|
|
24
|
+
* the server, so the host passes the base it was served from.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** The path segment under a mount base that a plugin page lives at. */
|
|
28
|
+
export const PLUGIN_PAGE_SEGMENT = 'apps';
|
|
29
|
+
|
|
30
|
+
export interface PluginPageTarget {
|
|
31
|
+
plugin: string;
|
|
32
|
+
view: string;
|
|
33
|
+
/** Adapter-qualified path of the row the action was invoked on. */
|
|
34
|
+
path?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** `apps/sign/wizard?path=docs%3A%2F%2Fnda.pdf` — no leading slash. */
|
|
38
|
+
export function pluginPagePath(target: PluginPageTarget): string {
|
|
39
|
+
const p = `${PLUGIN_PAGE_SEGMENT}/${encodeURIComponent(target.plugin)}/${encodeURIComponent(target.view)}`;
|
|
40
|
+
return target.path ? `${p}?path=${encodeURIComponent(target.path)}` : p;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The full address, given the base the SPA is mounted at (`/admin/`,
|
|
45
|
+
* `/drive/`, `https://fm.example.com/admin/` — with or without its trailing
|
|
46
|
+
* slash). An empty base yields a root-relative `/apps/…`, which is right for
|
|
47
|
+
* a host that serves the SPA at the root and wrong for one that does not —
|
|
48
|
+
* hence the config field rather than a guess.
|
|
49
|
+
*/
|
|
50
|
+
export function pluginPageUrl(base: string | undefined | null, target: PluginPageTarget): string {
|
|
51
|
+
const b = String(base ?? '').replace(/\/+$/, '');
|
|
52
|
+
return `${b}/${pluginPagePath(target)}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Does this action open its view as a full page rather than a dialog? */
|
|
56
|
+
export function isPagePlacement(placement: string | undefined | null): boolean {
|
|
57
|
+
return placement === 'page';
|
|
58
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* plural — which form of a counted message a language reads for a number.
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ Why this exists. Both catalogues used to have ONE plural rule, English's:
|
|
5
|
+
* the explorer chose `<key>_one` when the count was exactly 1 and `<key>`
|
|
6
|
+
* otherwise, and the admin panel left vue-i18n on its default (two forms:
|
|
7
|
+
* 1 → first, else second; three forms: 0 / 1 / else). That is enough for
|
|
8
|
+
* English and Turkish and for nothing much else. The Arabic pack's translator
|
|
9
|
+
* could not write the language at all — Arabic has SIX cardinal categories
|
|
10
|
+
* (zero, one, two, few, many, other) — and Russian, Polish, Czech and
|
|
11
|
+
* Ukrainian need few/many beside one (found by the Arabic pack, v0.43.0).
|
|
12
|
+
*
|
|
13
|
+
* The categories are the Unicode CLDR's, and they come from the runtime's own
|
|
14
|
+
* `Intl.PluralRules`: every browser and Node ship the CLDR tables, so the
|
|
15
|
+
* product does not carry (and cannot get out of date with) a copy of them.
|
|
16
|
+
*
|
|
17
|
+
* The grammar a pack writes, which `docs/` and the pack validator describe:
|
|
18
|
+
*
|
|
19
|
+
* explorer (packages/core/src/locales, flat keys)
|
|
20
|
+
* `<key>_zero` `<key>_one` `<key>_two` `<key>_few` `<key>_many`, and the
|
|
21
|
+
* plain `<key>` IS the `other` form (there is no `_other` key). A language
|
|
22
|
+
* writes only the categories it has; see `useLocale → countedKey`.
|
|
23
|
+
*
|
|
24
|
+
* admin (web/src/locales, vue-i18n `a | b | …`)
|
|
25
|
+
* as many forms as the language has categories (those whole numbers
|
|
26
|
+
* 0 … 999 reach, plus `other` — see `pluralCategories`), in CLDR order —
|
|
27
|
+
* Arabic `zero | one | two | few | many | other`, Russian `one | few |
|
|
28
|
+
* many | other` — or the old 1 / 2 / 3-form shapes; see
|
|
29
|
+
* `pluralChoiceIndex`.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The CLDR cardinal categories in the order a message lists its forms. */
|
|
33
|
+
export const CLDR_ORDER = ['zero', 'one', 'two', 'few', 'many', 'other'] as const;
|
|
34
|
+
export type PluralCategory = (typeof CLDR_ORDER)[number];
|
|
35
|
+
|
|
36
|
+
/** What a runtime with no usable `Intl.PluralRules` is treated as: English. */
|
|
37
|
+
const ENGLISH_CATEGORIES: readonly string[] = ['one', 'other'];
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One `Intl.PluralRules` per language. `null` = the runtime has none at all.
|
|
41
|
+
*
|
|
42
|
+
* ⚠ Cached because `t()` asks once per counted string per repaint, and
|
|
43
|
+
* constructing a `PluralRules` is the expensive part (`select` is cheap). A
|
|
44
|
+
* session has two or three distinct tags.
|
|
45
|
+
*/
|
|
46
|
+
const rulesCache = new Map<string, Intl.PluralRules | null>();
|
|
47
|
+
|
|
48
|
+
function rulesFor(lang: string | undefined | null): Intl.PluralRules | null {
|
|
49
|
+
const key = String(lang ?? '');
|
|
50
|
+
const hit = rulesCache.get(key);
|
|
51
|
+
if (hit !== undefined) return hit;
|
|
52
|
+
let rules: Intl.PluralRules | null = null;
|
|
53
|
+
if (typeof Intl !== 'undefined' && typeof Intl.PluralRules === 'function') {
|
|
54
|
+
try {
|
|
55
|
+
rules = new Intl.PluralRules(key || 'en');
|
|
56
|
+
} catch {
|
|
57
|
+
// ⚠ A tag `Intl` refuses (`RangeError: Incorrect locale information`)
|
|
58
|
+
// must not take the page down with it — a pack names its language
|
|
59
|
+
// itself, and the host only checks that it LOOKS like a tag. English
|
|
60
|
+
// rules are what the rest of the interface falls back to as well.
|
|
61
|
+
try {
|
|
62
|
+
rules = new Intl.PluralRules('en');
|
|
63
|
+
} catch {
|
|
64
|
+
rules = null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
rulesCache.set(key, rules);
|
|
69
|
+
return rules;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const categoriesCache = new Map<string, readonly string[]>();
|
|
73
|
+
|
|
74
|
+
/** The whole numbers sampled to find a language's categories: 0 … 999. */
|
|
75
|
+
const SAMPLE_MAX = 999;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The categories `lang` HAS, in CLDR order: every category some whole number
|
|
79
|
+
* from 0 to 999 falls in, plus `other` always.
|
|
80
|
+
*
|
|
81
|
+
* ⚠⚠ NOT `resolvedOptions().pluralCategories`. Current ICU lists `many` for
|
|
82
|
+
* Spanish, French, Italian, Portuguese and Catalan — a category only exact
|
|
83
|
+
* millions and compact numbers reach (`select(1000000)` is 'many') — so
|
|
84
|
+
* Spanish would have three categories, and a Spanish admin string written in
|
|
85
|
+
* the classic three forms `zero | one | other` would be read in CLDR order
|
|
86
|
+
* (`one | many | other`): 1 would pick the "zero" form. Counting what whole
|
|
87
|
+
* numbers people actually see select gives es/fr/it/pt `one | other`, Russian
|
|
88
|
+
* `one | few | many | other`, Arabic all six, Japanese `other`, Latvian
|
|
89
|
+
* `zero | one | other`, Romanian `one | few | other`.
|
|
90
|
+
*
|
|
91
|
+
* ⚠ The SAME definition as the pack validator (scripts/i18n-validate.mjs) and
|
|
92
|
+
* the server (Go, golang.org/x/text CLDR tables, sampled 0 … 999 + other). A
|
|
93
|
+
* third definition here would pass a pack there that renders wrong here.
|
|
94
|
+
*
|
|
95
|
+
* ⚠ In CLDR order, whatever order the runtime answers in: the admin panel
|
|
96
|
+
* maps a message's forms to categories BY POSITION — an unsorted list would
|
|
97
|
+
* hand Arabic's "few" form to the number 0.
|
|
98
|
+
*
|
|
99
|
+
* Cached: a thousand `select` calls, once per language per page.
|
|
100
|
+
*/
|
|
101
|
+
export function pluralCategories(lang: string | undefined | null): readonly string[] {
|
|
102
|
+
const key = String(lang ?? '');
|
|
103
|
+
const hit = categoriesCache.get(key);
|
|
104
|
+
if (hit) return hit;
|
|
105
|
+
let out: readonly string[] = ENGLISH_CATEGORIES;
|
|
106
|
+
const rules = rulesFor(key);
|
|
107
|
+
if (rules) {
|
|
108
|
+
try {
|
|
109
|
+
const seen = new Set<string>(['other']);
|
|
110
|
+
for (let n = 0; n <= SAMPLE_MAX; n++) seen.add(rules.select(n));
|
|
111
|
+
out = CLDR_ORDER.filter((c) => seen.has(c));
|
|
112
|
+
} catch {
|
|
113
|
+
out = ENGLISH_CATEGORIES;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
categoriesCache.set(key, out);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The category `lang` puts the number `n` in — `'one'`, `'few'`, `'other'`…
|
|
122
|
+
*
|
|
123
|
+
* ⚠ The absolute value: "−1 day" agrees like "1 day", and `select` has no
|
|
124
|
+
* opinion about a sign. A count that is not a finite number (a formatted
|
|
125
|
+
* "1,234" that `Number()` could not read) is `other`, the plain form, which
|
|
126
|
+
* is what the single `_one` rule this replaced did with it too.
|
|
127
|
+
*/
|
|
128
|
+
export function pluralCategory(lang: string | undefined | null, n: number): string {
|
|
129
|
+
if (!Number.isFinite(n)) return 'other';
|
|
130
|
+
const abs = Math.abs(n);
|
|
131
|
+
const rules = rulesFor(lang);
|
|
132
|
+
if (!rules) return abs === 1 ? 'one' : 'other';
|
|
133
|
+
try {
|
|
134
|
+
return rules.select(abs);
|
|
135
|
+
} catch {
|
|
136
|
+
return abs === 1 ? 'one' : 'other';
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Which of a vue-i18n message's `|`-separated forms `lang` reads for `choice`
|
|
142
|
+
* — a vue-i18n `pluralRules` entry, `(choice, choicesLength) => index`.
|
|
143
|
+
*
|
|
144
|
+
* - as many forms as the language has categories (more than one, and
|
|
145
|
+
* "has" as `pluralCategories` counts them): those categories, in CLDR
|
|
146
|
+
* order — Arabic `zero | one | two | few | many | other`, Russian
|
|
147
|
+
* `one | few | many | other`, Romanian `one | few | other`;
|
|
148
|
+
* - one form: that form (a language that does not inflect);
|
|
149
|
+
* - two: `one | other`;
|
|
150
|
+
* - three: the classic `zero | one | other`;
|
|
151
|
+
* - anything else: the last form, which is the `other` one.
|
|
152
|
+
*
|
|
153
|
+
* ⚠ The first rule outranks the next three, so a language with exactly three
|
|
154
|
+
* categories (Latvian, Romanian) writes three forms in CLDR order, not as
|
|
155
|
+
* `zero | one | other`. Spanish, French, Italian and Portuguese are NOT among
|
|
156
|
+
* them — see `pluralCategories` on the `many` ICU lists for round millions —
|
|
157
|
+
* so their three-form strings stay classic.
|
|
158
|
+
*
|
|
159
|
+
* ⚠ English and Turkish are not routed here — they keep vue-i18n's own rule,
|
|
160
|
+
* which the shipped catalogues were written against (web/src/i18n).
|
|
161
|
+
*/
|
|
162
|
+
export function pluralChoiceIndex(
|
|
163
|
+
lang: string | undefined | null,
|
|
164
|
+
choice: number,
|
|
165
|
+
choicesLength: number,
|
|
166
|
+
): number {
|
|
167
|
+
const n = Math.abs(choice);
|
|
168
|
+
const cats = pluralCategories(lang);
|
|
169
|
+
const c = pluralCategory(lang, n);
|
|
170
|
+
if (choicesLength === cats.length && choicesLength > 1) {
|
|
171
|
+
// ⚠ A category outside the sampled list is real: Spanish `select(1000000)`
|
|
172
|
+
// is 'many', and Spanish's list is `one | other`. It reads the last form
|
|
173
|
+
// (`other`) — -1 would make vue-i18n print nothing at all.
|
|
174
|
+
const i = cats.indexOf(c);
|
|
175
|
+
return i >= 0 ? i : choicesLength - 1;
|
|
176
|
+
}
|
|
177
|
+
if (choicesLength === 1) return 0;
|
|
178
|
+
if (choicesLength === 2) return c === 'one' ? 0 : 1;
|
|
179
|
+
if (choicesLength === 3) return n === 0 ? 0 : c === 'one' ? 1 : 2;
|
|
180
|
+
return choicesLength - 1;
|
|
181
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which layer a teleported popup has to sit on to be above whatever opened it.
|
|
3
|
+
*
|
|
4
|
+
* ⚠⚠ THE BUG THIS EXISTS FOR. A menu that teleports to `<body>` leaves its
|
|
5
|
+
* container's stacking context behind — that is the point, it is how a menu
|
|
6
|
+
* escapes `overflow: hidden` and a transformed ancestor. But it also leaves
|
|
7
|
+
* behind that container's PLACE in the stack. The explorer's Connections /
|
|
8
|
+
* API-keys overlay is `.fe-overlay`, `position: fixed; z-index: 130`; the
|
|
9
|
+
* context menu's backdrop is `z-index: 80`. So a row's "Actions" menu opened
|
|
10
|
+
* inside that overlay was painted UNDER it — the owner, testing v0.43.0:
|
|
11
|
+
* "explore kısmında api anahtarlarında ya da nasıl bağlanılar (yani popup
|
|
12
|
+
* ekranlar) içindeki aksiyon tuşlarının menüsü popup'ın altında kalıyor ve
|
|
13
|
+
* açılmıyor gibi gözüküyor". It opened every time; nobody could see it.
|
|
14
|
+
*
|
|
15
|
+
* ⚠ Why not simply raise `.fe-ctx-backdrop` to a big constant. Because the
|
|
16
|
+
* stack has deliberate orderings in it that a constant would flatten — the
|
|
17
|
+
* onboarding tour sits at 96 ABOVE the filter popovers at 90 on purpose, so a
|
|
18
|
+
* tour step is never hidden behind one. And in an EMBED the number we would
|
|
19
|
+
* have to beat is not ours at all: the host page decides what its own
|
|
20
|
+
* containers are worth, and no constant we pick is guaranteed to clear it.
|
|
21
|
+
*
|
|
22
|
+
* So the layer is MEASURED from the thing that opened the popup: walk the
|
|
23
|
+
* opener's ancestors, take the highest z-index any positioned one carries, and
|
|
24
|
+
* sit one above it — or stay on the base layer when nothing in the chain is
|
|
25
|
+
* raised at all, which is the ordinary case and leaves every existing ordering
|
|
26
|
+
* exactly as it was.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** The layer transient popups live on when nothing pushes them higher. */
|
|
30
|
+
export const POPUP_BASE_Z = 80;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The z-index for a popup opened from `openers`.
|
|
34
|
+
*
|
|
35
|
+
* Several openers may be passed because the caller usually has more than one
|
|
36
|
+
* imperfect clue about what was clicked (the focused element, the element under
|
|
37
|
+
* the pointer); the highest answer wins, and a null is simply ignored.
|
|
38
|
+
*/
|
|
39
|
+
export function popupLayer(
|
|
40
|
+
openers: Array<Element | null | undefined> | Element | null | undefined,
|
|
41
|
+
base: number = POPUP_BASE_Z,
|
|
42
|
+
): number {
|
|
43
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return base;
|
|
44
|
+
const list = Array.isArray(openers) ? openers : [openers];
|
|
45
|
+
let top = base;
|
|
46
|
+
for (const opener of list) {
|
|
47
|
+
let el: Element | null = opener ?? null;
|
|
48
|
+
/* A cycle is impossible through parentElement, but a shadow host hop is a
|
|
49
|
+
* jump the loop cannot prove terminates; 200 is far past any real tree. */
|
|
50
|
+
for (let guard = 0; el && guard < 200; guard++) {
|
|
51
|
+
if (el === document.body || el === document.documentElement) break;
|
|
52
|
+
let z = Number.NaN;
|
|
53
|
+
let positioned = false;
|
|
54
|
+
try {
|
|
55
|
+
const cs = window.getComputedStyle(el);
|
|
56
|
+
/* ⚠ `|| 'static'`: a DOM that has not resolved the cascade answers
|
|
57
|
+
* with the empty string rather than the initial value (happy-dom does,
|
|
58
|
+
* and so does a detached node in some engines). Reading that as "not
|
|
59
|
+
* static" would honour every inert `z-index` in the tree — the pinned
|
|
60
|
+
* Actions cell carries one — and shove the menu onto a layer nothing
|
|
61
|
+
* asked for. */
|
|
62
|
+
positioned = (cs.position || 'static') !== 'static';
|
|
63
|
+
z = Number.parseInt(cs.zIndex, 10);
|
|
64
|
+
} catch {
|
|
65
|
+
/* A detached or cross-document node: nothing to learn, keep climbing. */
|
|
66
|
+
}
|
|
67
|
+
/* ⚠ `position !== static` as well as a number: `z-index` on a static
|
|
68
|
+
* element is inert — honouring it would push the menu above a dialog
|
|
69
|
+
* because some unrelated cell in the row carried a leftover number. */
|
|
70
|
+
if (positioned && Number.isFinite(z) && z + 1 > top) top = z + 1;
|
|
71
|
+
el =
|
|
72
|
+
el.parentElement ??
|
|
73
|
+
/* Out the top of a shadow tree, if the host put us in one. */
|
|
74
|
+
((el.getRootNode() as ShadowRoot | null)?.host ?? null);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return top;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The best guesses at what opened a popup, at the moment it opens.
|
|
82
|
+
*
|
|
83
|
+
* `document.activeElement` is the control a click just focused, and the element
|
|
84
|
+
* under the pointer is what a right-click or a synthetic open landed on. Either
|
|
85
|
+
* can be wrong on its own — a button that does not take focus, a coordinate on
|
|
86
|
+
* a border — and both are in the same subtree as the real opener whenever they
|
|
87
|
+
* are right, which is all `popupLayer` needs.
|
|
88
|
+
*
|
|
89
|
+
* ⚠ Call this BEFORE the popup renders. Once the backdrop is in the document it
|
|
90
|
+
* covers the viewport, and `elementFromPoint` answers with the backdrop.
|
|
91
|
+
*/
|
|
92
|
+
export function openerCandidates(x?: number, y?: number): Array<Element | null> {
|
|
93
|
+
if (typeof document === 'undefined') return [];
|
|
94
|
+
const out: Array<Element | null> = [document.activeElement];
|
|
95
|
+
if (typeof x === 'number' && typeof y === 'number' && document.elementFromPoint) {
|
|
96
|
+
try {
|
|
97
|
+
out.push(document.elementFromPoint(x, y));
|
|
98
|
+
} catch {
|
|
99
|
+
/* Out-of-viewport coordinates throw in some engines; the focus clue stands. */
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|