diff --git a/api/js/etemplate/Et2Vfs/Et2VfsSelect.styles.ts b/api/js/etemplate/Et2Vfs/Et2VfsSelect.styles.ts index bff37ad8d4..a2e671f1ca 100644 --- a/api/js/etemplate/Et2Vfs/Et2VfsSelect.styles.ts +++ b/api/js/etemplate/Et2Vfs/Et2VfsSelect.styles.ts @@ -8,6 +8,7 @@ export default css` et2-dialog::part(body) { display: flex; flex-direction: column; + max-height: 40em; } .vfs_select__listbox { @@ -19,6 +20,7 @@ export default css` .vfs_select__listbox .vfs_select__empty { height: 100%; min-height: 5em; + min-width: 20em; display: flex; flex-direction: column; align-items: center; @@ -26,6 +28,18 @@ export default css` user-select: none; } + .vfs_select__file_row { + display: table-row; + } + + .vfs_select__listbox .vfs_select__loading { + text-align: center; + line-height: 15em; // 3 * listbox min height + } + + .vfs_select__listbox sl-spinner { + font-size: 4rem; + } .vfs_select__listbox .vfs_select__empty et2-image { margin-top: auto; } diff --git a/api/js/etemplate/Et2Vfs/Et2VfsSelect.ts b/api/js/etemplate/Et2Vfs/Et2VfsSelect.ts index a8de435edc..96e161740d 100644 --- a/api/js/etemplate/Et2Vfs/Et2VfsSelect.ts +++ b/api/js/etemplate/Et2Vfs/Et2VfsSelect.ts @@ -8,7 +8,7 @@ */ import {Et2InputWidget} from "../Et2InputWidget/Et2InputWidget"; -import {html, LitElement, nothing, TemplateResult} from "lit"; +import {html, LitElement, nothing, PropertyValues, TemplateResult} from "lit"; import shoelace from "../Styles/shoelace"; import styles from "./Et2VfsSelect.styles"; import {property} from "lit/decorators/property.js"; @@ -22,6 +22,7 @@ import {DialogButton, Et2Dialog} from "../Et2Dialog/Et2Dialog"; import {HasSlotController} from "../Et2Widget/slot"; import {IegwAppLocal} from "../../jsapi/egw_global"; import {Et2Select} from "../Et2Select/Et2Select"; +import {Et2VfsSelectRow} from "./Et2VfsSelectRow"; /** * @summary Select files (including directories) from the VFS @@ -29,6 +30,7 @@ import {Et2Select} from "../Et2Select/Et2Select"; * * @dependency et2-dialog * @dependency et2-select + * @dependency et2-vfs-select-row * * @slot title - Optional additions to title. Works best with `et2-button-icon`. * @slot toolbar - Toolbar containing controls for search & navigation @@ -70,10 +72,10 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi * Dialog mode * Quickly sets button label, multiple, selection and for "select-dir", mime-type **/ - @property() mode : "open" | "open-multiple" | "saveas" | "select-dir" = "open"; + @property({type: String}) mode : "open" | "open-multiple" | "saveas" | "select-dir"; /** Button label */ - @property() buttonLabel : string = "Select"; + @property({type: String}) buttonLabel : string = "Select"; /** Provide a suggested filename for saving */ @property() filename : string = ""; @@ -95,7 +97,9 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi @state() searching = false; @state() open : boolean = false; - @state() currentFile; + @state() currentFile : Et2VfsSelectRow; + @state() selectedFiles : Et2VfsSelectRow[] = []; + // SearchMixinInterface // @property() searchUrl : string = "EGroupware\\Api\\Etemplate\\Widget\\Vfs::ajax_vfsSelectFiles"; @@ -117,14 +121,16 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi protected readonly hasSlotController = new HasSlotController(this, 'help-text', 'toolbar', 'footer'); - protected _fileList = []; + protected _fileList : FileInfo[] = []; + // @ts-ignore different types + protected _appList : SelectOption[] = this.egw().link_app_list("query") ?? []; // Internal accessors get _dialog() : Et2Dialog { return this.shadowRoot.querySelector("et2-dialog");} get _filenameNode() : HTMLInputElement { return this.shadowRoot.querySelector("#filename");} - get _fileNodes() : HTMLElement[] { return Array.from(this.shadowRoot.querySelectorAll(".vfs_select__file"));} + get _fileNodes() : Et2VfsSelectRow[] { return Array.from(this.shadowRoot.querySelectorAll("et2-vfs-select-row"));} get _searchNode() : HTMLInputElement { return this.shadowRoot.querySelector("#search");} @@ -191,7 +197,7 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi if(this.path == "") { - this.path = "~"; + this.path = this.egw()?.preference("startfolder", "filemanager") || "~"; } // Get file list this.startSearch(); @@ -207,6 +213,27 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi return result; } + protected firstUpdated(changedProperties : PropertyValues) + { + super.firstUpdated(changedProperties); + debugger; + } + + protected willUpdate(changedProperties : PropertyValues) + { + super.willUpdate(changedProperties); + + if(changedProperties.has("mode")) + { + this.multiple = this.mode == "open-multiple"; + } + + if(changedProperties.has("path")) + { + this.startSearch(); + } + } + public setPath(path) { if(path == '..') @@ -244,6 +271,7 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi this.startSearch(); } return Promise.all([ + this.updateComplete, this._searchPromise, this._dialog.show() ]); @@ -258,6 +286,14 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi return this._dialog.hide(); } + getComplete() : Promise + { + return this._dialog.getComplete().then(() => + { + return this.value; + }); + } + startSearch() : Promise { // Stop timeout timer @@ -285,8 +321,8 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi { // Include a limit, even if options don't, to avoid massive lists breaking the UI let sendOptions = { - path: this._pathNode?.value ?? this.path, - mime: this._mimeNode?.value ?? this.mime, + path: this.path, + mime: this.mime, num_rows: 100, ...options } @@ -298,10 +334,7 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi processRemoteResults(results) : FileInfo[] { - if(results.message) - { - this.helpText = results.message; - } + this.helpText = results.message ?? ""; this._fileList = results.files ?? []; return this._fileList; @@ -338,22 +371,178 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi } } + /** + * Sets the selected files + * @param {Et2VfsSelectRow | Et2VfsSelectRow[]} file + * @private + */ + private setSelectedFiles(file : Et2VfsSelectRow | Et2VfsSelectRow[]) + { + const newSelectedOptions = Array.isArray(file) ? file : [file]; + + // Clear existing selection + this._fileNodes.forEach(el => + { + el.selected = false; + el.requestUpdate("selected"); + }); + + // Set the new selection + if(newSelectedOptions.length) + { + newSelectedOptions.forEach(el => + { + el.selected = true; + el.requestUpdate("selected"); + }); + } + + // Update selection, value, and display label + this.selectionChanged(); + } + + /** + * Toggles a file's selected state + */ + private toggleFileSelection(file : Et2VfsSelectRow, force? : boolean) + { + if(force === true || force === false) + { + file.selected = force; + } + else + { + file.selected = !file.selected; + } + + file.requestUpdate("selected"); + this.selectionChanged(); + } + + /** + * This method must be called whenever the selection changes. It will update the selected file cache, the current + * value, and the display value + */ + private selectionChanged() + { + // Update selected files cache + this.selectedFiles = this._fileNodes.filter(el => el.selected); + + // Update the value + if(this.multiple) + { + this.value = this.selectedFiles.map(el => el.value.path); + + // TODO - show how many are selected? + /* + if(this.value.length === 0) + { + // When no items are selected, keep the value empty so the placeholder shows + this.displayLabel = ''; + } + else + { + this.displayLabel = this.localize.term('numOptionsSelected', this.selectedFiles.length); + } + + */ + } + else + { + this.value = [this.selectedFiles[0]?.value.path] ?? []; + } + } + protected handleButtonClick(event : MouseEvent) { - if(event.target.id !== "cancel") - { - - throw new Error("Method not implemented."); - } this.open = false; this.requestUpdate("open", true); } - protected handleCreateDirectory(event : MouseEvent | KeyboardEvent) + /** + * Create a new directory in the current one + * @param {MouseEvent | KeyboardEvent} event + * @returns {Promise} + * @protected + */ + protected async handleCreateDirectory(event : MouseEvent | KeyboardEvent) { - throw new Error("Method not implemented."); + // Get new directory name + let [button, value] = await Et2Dialog.show_prompt( + null, this.egw().lang('New directory'), this.egw().lang('Create directory') + ).getComplete(); + let dir = value.value; + + if(button && dir) + { + this.egw().request('EGroupware\\Api\\Etemplate\\Widget\\Vfs::ajax_create_dir', [dir, this.path]) + .then((msg) => + { + this.egw().message(msg); + this.setPath(this.path + '/' + dir); + }); + } } + handleFileClick(event : MouseEvent) + { + const target = event.target as HTMLElement; + const file : Et2VfsSelectRow = target.closest('et2-vfs-select-row'); + const oldValue = this.value; + + if(file && !file.disabled) + { + // Can't select a directory normally + if(file.value.isDir && this.mode != "select-dir") + { + return; + } + if(this.multiple) + { + this.toggleFileSelection(file); + } + else + { + this.setSelectedFiles(file); + } + + // Set focus after updating so the value is announced by screen readers + //this.updateComplete.then(() => this.displayInput.focus({ preventScroll: true })); + + if(this.value !== oldValue) + { + // Emit after updating + this.updateComplete.then(() => + { + this.dispatchEvent(new Event('change', {bubbles: true})); + }); + } + } + } + + handleFileDoubleClick(event : MouseEvent) + { + const target = event.target as HTMLElement; + const file : Et2VfsSelectRow = target.closest('et2-vfs-select-row'); + + if(file.value.isDir) + { + this.toggleFileSelection(file, false); + const oldPath = this.path; + this.setPath(file.value.path); + } + else + { + // Not a dir, just select it + this.handleFileClick(event); + + // If we only want one, we've got it. Close. + if(!this.multiple) + { + this.hide(); + } + } + } handleSearchKeyDown(event) { clearTimeout(this._searchTimeout); @@ -362,7 +551,7 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi if(['ArrowDown', 'ArrowUp'].includes(event.key) && this._fileList.length) { event.stopPropagation(); - this.setCurrentOption(this._fileNodes[0]); + this.setCurrentFile(this._fileNodes[0]); return; } // Start search immediately @@ -374,8 +563,10 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi } else if(event.key == "Escape") { + event.stopPropagation(); + event.preventDefault(); this.value = []; - this.close(); + this.hide(); return; } @@ -408,7 +599,11 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi image="filemanager/fav_filter" noSubmit="true" @click=${() => this.setPath("/apps/favorites")} > - + this.setPath("/apps/" + e.target.value)} + > + + onFinish="app.vfsSelectUI.storeFile"> `; @@ -428,28 +624,38 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi protected filesTemplate() { const empty = this._fileList.length == 0; - const noFilesTemplate = html` -
- - ${this.egw().lang("no files in this directory.")} -
`; + const promise = this._searchPromise.then(() => { return html` - ${empty ? noFilesTemplate : html` + ${empty ? this.noFilesTemplate() : html` ${repeat(this._fileList, (file) => file.path, (file, index) => { return html` - - ${file.name}`; + `; } )}` }`; }); return html` - ${until(promise, html``)}`; + ${until(promise, html` +
+ +
`)}`; + } + + protected noFilesTemplate() : TemplateResult + { + return html` +
+ + ${this.egw().lang("no files in this directory.")} +
`; } protected mimeOptionsTemplate() @@ -502,8 +708,8 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi return html` @@ -562,7 +768,6 @@ export class Et2VfsSelect extends Et2InputWidget(LitElement) implements SearchMi `; } - } customElements.define("et2-vfs-select", Et2VfsSelect); @@ -573,4 +778,6 @@ export interface FileInfo mime : string, isDir : boolean, path? : string, + // We want to show it, but not act with it. File is disabled for the UI + disabled? : boolean } \ No newline at end of file diff --git a/api/js/etemplate/Et2Vfs/Et2VfsSelectRow.styles.ts b/api/js/etemplate/Et2Vfs/Et2VfsSelectRow.styles.ts new file mode 100644 index 0000000000..2d14e79d72 --- /dev/null +++ b/api/js/etemplate/Et2Vfs/Et2VfsSelectRow.styles.ts @@ -0,0 +1,96 @@ +import {css} from 'lit'; + +export default css` + :host { + display: block; + user-select: none; + } + + :host(:focus) { + outline: none; + } + + :host([disabled]) { + display: block; + } + + .file { + position: relative; + display: flex; + align-items: center; + font-family: var(--sl-font-sans); + font-size: var(--sl-font-size-medium); + font-weight: var(--sl-font-weight-normal); + line-height: var(--sl-line-height-normal); + letter-spacing: var(--sl-letter-spacing-normal); + color: var(--sl-color-neutral-700); + padding: var(--sl-spacing-2x-small) var(--sl-spacing-medium) var(--sl-spacing-2x-small) var(--sl-spacing-x-small); + transition: var(--sl-transition-fast) fill; + cursor: pointer; + } + + .file--hover:not(.file--current):not(.file--disabled) { + background-color: var(--sl-color-neutral-100); + color: var(--sl-color-neutral-1000); + } + + .file--current, + .file--current.file--disabled { + background-color: var(--sl-color-primary-600); + color: var(--sl-color-neutral-0); + opacity: 1; + } + + .file--disabled { + outline: none; + opacity: 0.5; + cursor: not-allowed; + } + + .file__label { + flex: 1 1 auto; + display: inline-block; + line-height: var(--sl-line-height-dense); + } + + .file .file__check { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + visibility: hidden; + padding-inline-end: var(--sl-spacing-2x-small); + } + + .file et2-vfs-mime { + /* line-height-normal has no unit */ + height: calc(var(--sl-line-height-normal) * 1em); + padding-inline-end: var(--sl-spacing-medium); + } + + .file--selected .file__check { + visibility: visible; + } + + .file__prefix, + .file__suffix { + flex: 0 0 auto; + display: flex; + align-items: center; + } + + .file__prefix::slotted(*) { + margin-inline-end: var(--sl-spacing-x-small); + } + + .file__suffix::slotted(*) { + margin-inline-start: var(--sl-spacing-x-small); + } + + @media (forced-colors: active) { + :host(:hover:not([aria-disabled='true'])) .file { + outline: dashed 1px SelectedItem; + outline-offset: -1px; + } + } +`; diff --git a/api/js/etemplate/Et2Vfs/Et2VfsSelectRow.ts b/api/js/etemplate/Et2Vfs/Et2VfsSelectRow.ts new file mode 100644 index 0000000000..cc62de9b4f --- /dev/null +++ b/api/js/etemplate/Et2Vfs/Et2VfsSelectRow.ts @@ -0,0 +1,75 @@ +import {html, LitElement} from "lit"; +import {Et2Widget} from "../Et2Widget/Et2Widget"; +import {FileInfo} from "./Et2VfsSelect"; +import {property} from "lit/decorators/property.js"; +import {state} from "lit/decorators/state.js"; +import {classMap} from "lit/directives/class-map.js"; +import shoelace from "../Styles/shoelace"; +import styles from "./Et2VfsSelectRow.styles"; + +export class Et2VfsSelectRow extends Et2Widget(LitElement) +{ + static get styles() + { + return [ + shoelace, + ...super.styles, + styles + ]; + } + + @property({type: Object}) value : FileInfo; + + /** Draws the file in a disabled state, preventing selection. */ + @property({type: Boolean, reflect: true}) disabled = false; + + @state() current = false; // the user has keyed into the file, but hasn't selected it yet (shows a highlight) + @state() selected = false; // the file is selected and has aria-selected="true" + @state() hasHover = false; // we need this because Safari doesn't honor :hover styles while dragging + + connectedCallback() + { + super.connectedCallback(); + this.setAttribute('role', 'option'); + this.setAttribute('aria-selected', 'false'); + } + + private handleMouseEnter() + { + this.hasHover = true; + this.requestUpdate("hasHover", false); + } + + private handleMouseLeave() + { + this.hasHover = false; + this.requestUpdate("hasHover", true); + } + + render() + { + return html` +
+ + + + ${this.value.name} + +
+ `; + } +} + +customElements.define("et2-vfs-select-row", Et2VfsSelectRow); \ No newline at end of file diff --git a/api/js/etemplate/etemplate2.ts b/api/js/etemplate/etemplate2.ts index e6531fed27..4c48d9c556 100644 --- a/api/js/etemplate/etemplate2.ts +++ b/api/js/etemplate/etemplate2.ts @@ -98,6 +98,7 @@ import "./Layout/Et2Split/Et2Split"; import "./Layout/RowLimitedMixin"; import "./Et2Vfs/Et2VfsMime"; import "./Et2Vfs/Et2VfsSelect"; +import "./Et2Vfs/Et2VfsSelectRow"; import "./Et2Vfs/Et2VfsUid"; import "./Et2Textbox/Et2Password"; import './Et2Textbox/Et2Searchbox'; diff --git a/api/src/Etemplate/Widget/Vfs.php b/api/src/Etemplate/Widget/Vfs.php index 0c636c1874..46c39db268 100644 --- a/api/src/Etemplate/Widget/Vfs.php +++ b/api/src/Etemplate/Widget/Vfs.php @@ -612,49 +612,129 @@ class Vfs extends File */ public static function ajax_vfsSelectFiles($search, $content) { - $pathIn = $options['path'] ?? '~'; - if($pathIn == '~') + $response = []; + $content['path'] = $content['path'] ?? '~'; + if($content['path'] == '~') { - $pathIn = Api\Vfs::get_home_dir(); + $content['path'] = Api\Vfs::get_home_dir(); } - $content = []; - if(!($files = Api\Vfs::find($pathIn, array( + + // Filemanager favorites as directories + if(substr($content['path'], 0, strlen('/apps/favorites')) == '/apps/favorites') + { + $files = static::filesFromFavorites($search, $content); + } + else + { + $files = static::filesFromVfs($search, $content); + if(is_string($files)) + { + $response['message'] = $files; + $files = []; + } + } + foreach($files as $path) + { + if(is_string($path) && $path == $content['path'] || is_array($path) && $path['path'] == $content['path']) + { + // remove directory itself + continue; + } + $name = $path['name'] ?? Api\Vfs::basename($path); + $is_dir = $path['isDir'] ?? Api\Vfs::is_dir($path); + $mime = $path['mime'] ?? Api\Vfs::mime_content_type($path); + if($content['mime'] && !$is_dir && $mime != $content['mime']) + { + continue; // does not match mime-filter --> ignore + } + $response['files'][] = array( + 'name' => $name, + 'path' => $path, + 'mime' => $mime, + 'isDir' => $is_dir + ); + } + Json\Response::get()->data($response); + } + + private static function filesFromVfs($search, $params) + { + $vfs_options = array( 'dirsontop' => true, 'order' => 'name', 'sort' => 'ASC', 'maxdepth' => 1, - )))) + ); + if($search) { - $content['message'] = lang("Can't open directory %1!", $pathIn); + $vfs_options['name_preg'] = '/' . str_replace(array('\\?', '\\*'), + array('.{1}', '.*'), + preg_quote($search)) . '/i'; } - else + if($params['num_rows']) { - $n = 0; - foreach($files as $path) - { - if($path == $pathIn) - { - continue; - } // remove directory itself + $vfs_options['limit'] = (int)$params['num_rows']; + } + if(!($files = Api\Vfs::find($params['path'], $vfs_options))) + { + return lang("Can't open directory %1!", $params['path']); + } + return $files; + } - $name = Api\Vfs::basename($path); - $is_dir = Api\Vfs::is_dir($path); - $mime = Api\Vfs::mime_content_type($path); - if($content['mime'] && !$is_dir && $mime != $content['mime']) - { - continue; // does not match mime-filter --> ignore - } - $content['files'][$n] = array( - 'name' => $name, - 'path' => $path, + /** + * Get favorites as if they were folders + * + * @return array + */ + private static function filesFromFavorites($search, $params) + { + + // Display favorites as if they were folders + $files = array(); + $favorites = Api\Framework\Favorites::get_favorites('filemanager'); + + //check for recent paths and add them to the top of favorites list + if(is_array($params['recentPaths'])) + { + foreach($params['recentPaths'] as $p) + { + $mime = Api\Vfs::mime_content_type($p); + $files[] = array( + 'name' => $p, + 'path' => $p, 'mime' => $mime, - 'is_dir' => $is_dir + 'is_dir' => true ); - ++$n; } } - $response = Json\Response::get(); - $response->data($content); + + foreach($favorites as $favorite) + { + $path = $favorite['state']['path']; + if(!$path) + { + continue; + } + // Search + if($search && !(str_contains($favorite['name'], $search) || str_contains($path, $search))) + { + continue; + } + if(!Api\Vfs::is_readable($path)) + { + continue; + } + + $mime = Api\Vfs::mime_content_type($path); + $files[] = array( + 'name' => $favorite['name'], + 'path' => $path, + 'mime' => $mime, + 'isDir' => true + ); + } + return $files; } /**