From 7b0b5a143d84bd29222497ae03eb58dc71964f13 Mon Sep 17 00:00:00 2001 From: b-ma Date: Fri, 26 Jul 2024 12:34:29 +0200 Subject: [PATCH] feat: improved ux when editable --- docs/src/sc-filetree.js | 52 +++- src/sc-filetree.js | 606 ++++++++++++++++++++++++++++++++-------- 2 files changed, 522 insertions(+), 136 deletions(-) diff --git a/docs/src/sc-filetree.js b/docs/src/sc-filetree.js index 2e6ebe1..9769905 100644 --- a/docs/src/sc-filetree.js +++ b/docs/src/sc-filetree.js @@ -7,40 +7,62 @@ const tree = { name: 'docs', children: [ { - path: 'docs/inner', - name: 'inner', + path: 'docs/TODOS.md', + name: 'TODOS.md', + size: 1588, + extension: '.md', + type: 'file' + }, + { + path: 'docs/src', + name: 'src', children: [ { - path: 'docs/inner/niap.md', - name: 'niap.md', + path: 'docs/src/index.js', + name: 'index.js', size: 1584, extension: '.md', type: 'file' }, { - path: 'docs/inner/test.md', - name: 'test.md', + path: 'docs/src/utils.js', + name: 'utils.js', size: 1588, extension: '.md', type: 'file' - } + }, ], size: 3172, type: 'directory' }, { - path: 'docs/niap.md', - name: 'niap.md', + path: 'docs/README.md', + name: 'README.md', size: 1584, extension: '.md', type: 'file' }, { - path: 'docs/test.md', - name: 'test.md', - size: 1588, - extension: '.md', - type: 'file' + path: 'docs/www', + name: 'www', + children: [ + { + path: 'docs/www/index.html', + name: 'index.html', + size: 1584, + extension: '.md', + type: 'file' + }, + { + path: 'docs/www/styles.css', + name: 'styles.css', + size: 1588, + extension: '.md', + type: 'file' + }, + ], + size: 3172, + type: 'directory' }, ], size: 3172, @@ -72,6 +94,8 @@ const template = html\`

The data format used by the component to render the file tree is based on the format proposed by the directory-tree library. +
+ Note that for now only the fields: "name", "path", and "type" of the entries are actually used by the component.

Events

diff --git a/src/sc-filetree.js b/src/sc-filetree.js index 206b3dc..26a6323 100644 --- a/src/sc-filetree.js +++ b/src/sc-filetree.js @@ -1,7 +1,9 @@ import { html, css, nothing } from 'lit'; import { classMap } from 'lit/directives/class-map.js'; +import { isString, isFunction } from '@ircam/sc-utils'; import ScElement from './ScElement.js'; +import KeyboardController from './controllers/keyboard-controller.js'; import './utils/sc-context-menu.js'; import './sc-text.js'; @@ -23,7 +25,7 @@ class ScFileTree extends ScElement { type: Object, state: true, }, - _updateTreeInfos: { + _contextMenuCommand: { type: Object, state: true, }, @@ -45,13 +47,18 @@ class ScFileTree extends ScElement { padding: 0; position: relative; - background-color: var(--sc-color-primary-2); - --sc-filetree-hover-background-color: var(--sc-color-primary-3); - --sc-filetree-active-background-color: var(--sc-color-primary-4); + background-color: var(--sc-color-primary-1); + --sc-filetree-hover-background-color: var(--sc-color-primary-2); + --sc-filetree-active-background-color: var(--sc-color-primary-3); + --sc-filetree-keyboard-selected-outline-color: var(--sc-color-primary-4); } :host([hidden]) { - display: none + display: none; + } + + :host(:focus), :host(:focus-visible) { + outline: none; } ul { @@ -68,6 +75,12 @@ class ScFileTree extends ScElement { position: relative; min-height: 22px; vertical-align: middle; + box-sizing: border-box; + } + + /* empty folder */ + li.sub-directory { + min-height: 0; } li span { @@ -76,7 +89,7 @@ class ScFileTree extends ScElement { display: inline-block; } - li .hover, li .hover-bg { + li .hover, li .hover-bg, li .keyboard-selection { position: absolute; top: 0; left: 0; @@ -84,6 +97,7 @@ class ScFileTree extends ScElement { width: 100%; background-color: transparent; z-index: 0; + box-sizing: border-box; } li .content { @@ -95,23 +109,32 @@ class ScFileTree extends ScElement { z-index: 2; } - li.trigger-context-menu .hover + .hover-bg { + li .hover:hover + .hover-bg { background-color: var(--sc-filetree-hover-background-color); } - li .hover:hover + .hover-bg { + li.trigger-context-menu .hover + .hover-bg { background-color: var(--sc-filetree-hover-background-color); } - li.active > .hover-bg, li.active .hover:hover + .hover-bg { + li.active > .hover-bg, + li.active .hover:hover + .hover-bg { background-color: var(--sc-filetree-active-background-color); } - li.directory + li { + li .keyboard-selection { + display: none; + border: 1px dotted var(--sc-filetree-keyboard-selected-outline-color); + } + li.keyboard-selected .keyboard-selection { + display: block; + } + + li.sub-directory { display: none; } - li.open + li { + li.sub-directory.open { display: block; } @@ -150,10 +173,12 @@ class ScFileTree extends ScElement { sc-text { width: 100%; - position: absolute; z-index: 10; - left: 0; - bottom: 0; + height: 22px; + } + + sc-text.errored { + color: var(--sc-color-secondary-3); } `; @@ -165,25 +190,152 @@ class ScFileTree extends ScElement { const oldValue = this._editable; // reset everything this._contextMenuInfos = null; - this._updateTreeInfos = null; + this._contextMenuCommand = null; this._editable = value; this.requestUpdate('editable', oldValue); } + get value() { + return this._value; + } + + set value(value) { + // deep copy value first to make ure we don't modify twice the same reference + value = JSON.parse(JSON.stringify(value)); + + (function sanitize(node) { + if (!isString(node.path)) { + throw new Error(`Cannot set 'value' of sc-filetree: Nodes should have a valid 'path' key`); + } + + if (!isString(node.name)) { + throw new Error(`Cannot set 'value' of sc-filetree: Node (${node.path}) should have a valid 'name' key`); + } + + if (!['directory', 'file'].includes(node.type)) { + throw new Error(`Cannot set 'value' of sc-filetree: Node (${node.path}) should have a valid 'type' key`); + } + + if (node.type === 'directory' && !Array.isArray(node.children)) { + throw new Error(`Cannot set 'value' of sc-filetree: Node (${node.path}) with type 'directory' should have a valid 'children' key`); + } + + // copy a sanitized copy of self into self for propagation in events + // avoid cpoying children as this could tend to be overkill memory wize + // node.raw = JSON.parse(JSON.stringify(node)); + const raw = {}; + + for (let key in node) { + if (key !== 'children') { + raw[key] = node[key]; + } + } + + node.raw = raw; + + if (node.children) { + node.children.map(child => sanitize(child)); + } + }(value)); + + (function sort(node) { + if (!node.children) { + return; + } + + node.children.sort((a, b) => { + if (a.type === 'directory' && b.type === 'file') { + return -1; + } else if (a.type === 'file' && b.type === 'directory') { + return 1; + } else { + return a.name.toLowerCase() < b.name.toLowerCase() ? -1 : 1; + } + }); + + // add pointers to sorted parent and siblings + for (let i = 0; i < node.children.length; i++) { + const child = node.children[i]; + child.parent = node; + child.prev = node.children[i - 1] || null; + child.next = node.children[i + 1] || null; + + sort(child); + } + }(value)); + + // root has no siblings or parent + value.parent = null; + value.prev = null; + value.next = null; + + this._value = value; + } + constructor() { super(); // when editable this._contextMenuInfos = null; - this._updateTreeInfos = null; + this._contextMenuCommand = null; this._editable = false; + this._value = null; - this.value = null; this.editable = false; // store the active "highlighted" element, directly modified from code, no state this._currentActive = null; + this._openDirectories = new Set(); + this._inputError = false; + + // @todo - handle arrows to navigate, open/close dir, and trigger rename + new KeyboardController(this, { + filterCodes: ['Escape', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Enter', 'Space'], + callback: this._onKeyboardEvent.bind(this), + }); + } + + render() { + return html` + ${this._contextMenuInfos !== null + ? html` +
+ ` + : nothing + } + + ` + } + + async updated(changedProperties) { + super.updated(); + + // @todo - implement disabled behavior + this.setAttribute('tabindex', this._tabindex); + + if (this._contextMenuCommand) { + await this.updateComplete; // sc-text input must be completely rendered to be focused + this.shadowRoot.querySelector('sc-text').focus(); + } + } + + connectedCallback() { + super.connectedCallback(); + // add top level directory to opened directories + this._openDirectories.add(this._value.path); + this._tabindex = this.getAttribute('tabindex') || 0; + } + + _isOpenDirectory(node) { + return node.type === 'directory' && this._openDirectories.has(node.path); } _renderNode(node, depth) { @@ -192,31 +344,60 @@ class ScFileTree extends ScElement { } const depthPadding = 16; - const paddingLeft = 6; - const classes = { + const paddingLeft = 10; + const dirClasses = { directory: (node.type === 'directory'), - open: (depth === 0), + open: this._isOpenDirectory(node), + } + + const subDirClasses = { + 'sub-directory': true, + open: this._isOpenDirectory(node), } return html`
  • this._onItemClick(e, node)} @contextmenu=${e => this._showContextMenu(e, node)} + .node=${node} + path=${node.path} > -
    -
    -
    - ${node.name} -
    + ${this._contextMenuCommand !== null && this._contextMenuCommand.node === node && this._contextMenuCommand.command === 'rename' + ? html` + ${this._contextMenuCommand.node.name} + ` + : html` +
    +
    +
    +
    + ${node.name} +
    + ` + }
  • ${node.type === 'directory' ? html` -
  • +
  • @@ -225,44 +406,118 @@ class ScFileTree extends ScElement { ` } - render() { - return html` - ${this._contextMenuInfos !== null - ? html` -
    - ` - : nothing - } - ${this._updateTreeInfos !== null - ? html` - e.stopPropagation()} - @change=${this._onTreeChange} - >${this._updateTreeInfos.command === 'rename' - ? this._updateTreeInfos.node.name - : '' - } - ` - : nothing + _onKeyboardEvent(e) { + e.stopPropagation(); + + if (e.type === 'keyup') { + switch (e.code) { + case 'Escape': { + if (this._contextMenuCommand !== null) { + this._contextMenuCommand = null; + this.requestUpdate(); + } + break; + } } - - ` - } + } - updated() { - super.updated(); + if (e.type === 'keydown') { + switch (e.code) { + case 'ArrowUp': { + const current = this._currentKeyboardActive.node; + let prev = current.prev || current.parent; + + // we have reached root node abort + if (prev === null) { + return; + } + + // if prev is a directory, it is opened and we come from its outside + while ( + prev.type === 'directory' + && this._openDirectories.has(prev.path) + && prev.children.length > 0 + && !prev.children.includes(current) + ) { + prev = prev.children[prev.children.length - 1]; + } + + // retrieve corresponding DOM element + const $el = this.shadowRoot.querySelector(`[path="${prev.path}"]`); + + this._currentKeyboardActive.classList.remove('keyboard-selected'); + this._currentKeyboardActive = $el; + this._currentKeyboardActive.classList.add('keyboard-selected'); + break; + } + case 'ArrowDown': { + const current = this._currentKeyboardActive.node; + let next = null; + + if ( + this._isOpenDirectory(current) + && current.children.length > 0 + ) { + next = current.children[0]; // enter into folder + } else if (current.next) { + next = current.next // next sibling + } else { // exit folder + let parent = current.parent; + next = parent.next; + + while (parent && next === null) { + parent = parent.parent; + + if (parent) { + next = parent.next; + } + } + } + + if (next === null) { + return; + } + + const $el = this.shadowRoot.querySelector(`[path="${next.path}"]`); + + this._currentKeyboardActive.classList.remove('keyboard-selected'); + this._currentKeyboardActive = $el; + this._currentKeyboardActive.classList.add('keyboard-selected'); + break; + } + case 'ArrowLeft': { + const node = this._currentKeyboardActive.node; + if (node.type === 'directory' && this._openDirectories.has(node.path)) { + this._openDirectories.delete(node.path); + this.requestUpdate(); + } + break; + } + case 'ArrowRight': { + const node = this._currentKeyboardActive.node; + if (node.type === 'directory' && !this._openDirectories.has(node.path)) { + this._openDirectories.add(node.path); + this.requestUpdate(); + } + break; + } + case 'Enter': { + if (!this._currentKeyboardActive) { + return; + } + + const node = this._currentKeyboardActive.node; + this._contextMenuCommand = { node, command: 'rename' }; + break; + } + case 'Space': { + if (this._currentKeyboardActive) { + this._selectTarget(this._currentKeyboardActive); + } + break; + } + } - if (this._updateTreeInfos) { - // we need to wait for the input to be rendered - setTimeout(() => this.shadowRoot.querySelector('sc-text').focus(), 0); } } @@ -270,51 +525,46 @@ class ScFileTree extends ScElement { e.stopPropagation(); if (node.type === 'directory') { - e.currentTarget.classList.toggle('open'); - } - - this._setActive(e.currentTarget); + if (this._openDirectories.has(node.path)) { + this._openDirectories.delete(node.path); + } else { + this._openDirectories.add(node.path); + } - const event = new CustomEvent('input', { - bubbles: true, - composed: true, - detail: { value: node }, - }); + this.requestUpdate(); + } - this.dispatchEvent(event); + this._selectTarget(e.currentTarget); } - _onTreeChange(e) { - // do not propagate change event from sc-text - e.stopPropagation(); + _selectTarget($el) { + const propagateEvent = $el !== this._currentActive; + this._setActive($el); - const pathname = e.detail.value; + if (propagateEvent) { + const event = new CustomEvent('input', { + bubbles: true, + composed: true, + detail: { value: $el.node.raw }, + }); - const { node, command } = this._updateTreeInfos; - const filename = e.detail.value.trim().replace('\n', ''); - const value = { command }; + this.dispatchEvent(event); + } + } - switch (command) { - case 'mkdir': - case 'writeFile': { - value.pathname = `${node.path}/${filename}`; - break; - } - case 'rename': { - value.oldPathname = node.path; - value.newPathname = node.path.replace(node.name, pathname); - break; - } + _setActive($el) { + if (this._currentActive) { + this._currentActive.classList.toggle('active'); } - const event = new CustomEvent('change', { - bubbles: true, - composed: true, - detail: { value }, - }); + $el.classList.toggle('active'); + this._currentActive = $el; - this.dispatchEvent(event); - this._updateTreeInfos = null; + if (this._currentKeyboardActive) { + this._currentKeyboardActive.classList.remove('keyboard-selected'); + } + + this._currentKeyboardActive = $el; } _onContextMenuCommand(e) { @@ -324,16 +574,16 @@ class ScFileTree extends ScElement { // // do not propagate input event from context menu e.stopPropagation(); - // set target event as active this._setActive(this._contextMenuInfos.$el); const command = e.detail.value; switch (command) { - case 'rm': { - const pathname = this._contextMenuInfos.node.path; - const value = { command, pathname }; + case 'delete': { + const absPath = this._contextMenuInfos.node.path; + const relPath = this._contextMenuInfos.node.relPath; + const value = { command, absPath, relPath }; const event = new CustomEvent('change', { bubbles: true, composed: true, @@ -343,23 +593,125 @@ class ScFileTree extends ScElement { this.dispatchEvent(event); break; } - case 'mkdir': - case 'writeFile': case 'rename': { const { node } = this._contextMenuInfos; - // show text input - this._updateTreeInfos = { node, command }; + this._contextMenuCommand = { node, command }; + break; + } + case 'mkdir': + case 'touch': { + // these only apply for directories, which should open if still closed + const { node } = this._contextMenuInfos; + this._openDirectories.add(node.path); + this._contextMenuCommand = { node, command }; + break; } } } - _setActive($el) { - if (this._currentActive) { - this._currentActive.classList.toggle('active'); + // if there is a "/" in filename, only keep last part + _sanitizeFilename(input) { + const parts = input.split('/'); + const filename = parts[parts.length - 1]; + return filename; + } + + async _checkFilename(e) { + e.stopPropagation(); + + const $input = e.currentTarget; + const filename = this._sanitizeFilename(e.detail.value); + const { node, command } = this._contextMenuCommand; + // make sure we can't create a file with same name + let exists = false; + let targetType; + let siblings; + + if (command === 'touch' || command === 'mkdir') { + targetType = command === 'touch' ? 'file' : 'directory'; + siblings = node.children; + } else if (command === 'rename') { + targetType = node.type; + siblings = node.parent.children; } - $el.classList.toggle('active'); - this._currentActive = $el; + for (let i = 0; i < siblings.length; i++) { + const sibling = siblings[i]; + + // for rename, do not check against itself + if (sibling === node) { + continue; + } + + if (sibling.type === targetType && sibling.name === filename) { + exists = true; + break; + } + } + + if (exists) { + this._inputError = true; + $input.classList.add('errored'); + } else { + this._inputError = false; + $input.classList.remove('errored'); + } + } + + async _finalizeContextMenuCommand(e) { + // do not propagate change event from sc-text + e.stopPropagation(); + // cannot finalize command if input is in errored state + if (this._inputError) { + return; + } + + // this can be called if the value has been before aborting (press Escape) + // probably due to blur event + if (this._contextMenuCommand === null) { + return; + } + + const { node, command } = this._contextMenuCommand; + const filename = this._sanitizeFilename(e.detail.value); + + if (filename === '') { + return; + } + + const rootDirname = this._value.name; + const re = new RegExp(`^${rootDirname}/`); + const value = { command }; + + switch (command) { + case 'mkdir': + case 'touch': { + value.absPath = `${node.path}/${filename}`; + value.relPath = value.absPath.replace(re, ''); + break; + } + case 'rename': { + value.oldAbsPath = node.path; + value.oldRelPath = value.oldAbsPath.replace(re, ''); + + value.newAbsPath = node.path.replace(node.name, filename); + value.newRelPath = value.newAbsPath.replace(re, ''); + break; + } + } + + const event = new CustomEvent('change', { + bubbles: true, + composed: true, + detail: { value }, + }); + + this.dispatchEvent(event); + + this._contextMenuCommand = null; + // we loose focus somehow, when renaming a file + await this.updateComplete; + this.focus(); } _showContextMenu(e, node) { @@ -376,22 +728,32 @@ class ScFileTree extends ScElement { return; } + // reset previous context menu command if it didn't go to its end + this._contextMenuCommand = null; + const $el = e.currentTarget; let options = null; - // follow soundworks plugin API - + // follow soundworks plugin filesystem / scripting API for commands if (node.type === 'directory') { - options = [ - { action: 'writeFile', label: 'New File' }, - { action: 'rename', label: 'Rename...' }, - { action: 'mkdir', label: 'New Folder...' }, - { action: 'rm', label: 'Delete Folder' }, - ]; + if (node === this.value) { + // cannot delete or rename root node + options = [ + { action: 'touch', label: 'New File' }, + { action: 'mkdir', label: 'New Folder...' }, + ]; + } else { + options = [ + { action: 'touch', label: 'New File' }, + { action: 'rename', label: 'Rename...' }, + { action: 'mkdir', label: 'New Folder...' }, + { action: 'delete', label: 'Delete Folder' }, + ]; + } } else { options = [ { action: 'rename', label: 'Rename...' }, - { action: 'rm', label: 'Delete File' }, + { action: 'delete', label: 'Delete File' }, ]; }