UNPKG

wunderbaum

Version:

JavaScript tree/grid/treegrid control.

391 lines (363 loc) 12.4 kB
/*! * Wunderbaum - ext-edit * Copyright (c) 2021-2025, Martin Wendt. Released under the MIT license. * @VERSION, @DATE (https://github.com/mar10/wunderbaum) */ import { Wunderbaum } from "./wunderbaum"; import { WunderbaumExtension } from "./wb_extension_base"; import { assert, escapeHtml, eventToString, getValueFromElem, isMac, isPlainObject, onEvent, ValidationError, } from "./util"; import { debounce } from "./debounce"; import { WunderbaumNode } from "./wb_node"; import { EditOptionsType, InsertNodeType, WbNodeData } from "./types"; // const START_MARKER = "\uFFF7"; export class EditExtension extends WunderbaumExtension<EditOptionsType> { protected debouncedOnChange: (e: Event) => void; protected curEditNode: WunderbaumNode | null = null; protected relatedNode: WunderbaumNode | null = null; constructor(tree: Wunderbaum) { super(tree, "edit", { debounce: 100, minlength: 1, maxlength: null, trigger: [], //["clickActive", "F2", "macEnter"], trim: true, select: true, slowClickDelay: 1000, // Handle 'clickActive' only if last click is less than this old (0: always) validity: true, //"Please enter a title", // --- Events --- // (note: there is also the `tree.change` event.) beforeEdit: null, edit: null, apply: null, }); this.debouncedOnChange = debounce( this._onChange.bind(this), this.getPluginOption("debounce") ); } /* * Call an event handler, while marking the current node cell 'busy'. * Deal with returned promises and ValidationError. * Convert a ValidationError into a input.setCustomValidity() call and vice versa. */ protected async _applyChange( eventName: string, node: WunderbaumNode, colElem: HTMLElement, inputElem: HTMLInputElement, extra: any ): Promise<any> { node.log(`_applyChange(${eventName})`, extra); colElem.classList.add("wb-busy"); colElem.classList.remove("wb-error", "wb-invalid"); inputElem.setCustomValidity(""); // Call event handler either ('change' or 'edit.appy'), which may return a // promise or a scalar value or throw a ValidationError. return new Promise((resolve, reject) => { const res = node._callEvent(eventName, extra); // normalize to promise, even if a scalar value was returned and await it Promise.resolve(res) .then((res) => { resolve(res); }) .catch((err) => { reject(err); }); }) .then((res) => { if (!inputElem.checkValidity()) { // Native validation failed or handler called 'inputElem.setCustomValidity()' node.logWarn("inputElem.checkValidity() failed: throwing...."); throw new ValidationError(inputElem.validationMessage); } return res; }) .catch((err) => { if (err instanceof ValidationError) { node.logWarn("catched ", err); colElem.classList.add("wb-invalid"); if (inputElem.setCustomValidity && !inputElem.validationMessage) { inputElem.setCustomValidity(err.message); } if (inputElem.validationMessage) { inputElem.reportValidity(); } // throw err; } else { node.logError( `Error in ${eventName} event handler (throw e.util.ValidationError instead if this was intended)`, err ); colElem.classList.add("wb-error"); throw err; } }) .finally(() => { colElem.classList.remove("wb-busy"); }); } /* * Called for when a control that is embedded in a cell fires a `change` event. */ protected _onChange(e: Event) { const info = Wunderbaum.getEventInfo(e); const node = info.node!; const colElem = <HTMLElement>info.colElem!; if (!node || info.colIdx === 0) { this.tree.log("Ignored change event for removed element or node title"); return; } // See also WbChangeEventType this._applyChange("change", node, colElem, e.target as HTMLInputElement, { info: info, event: e, inputElem: e.target, inputValue: Wunderbaum.util.getValueFromElem(e.target as HTMLElement), inputValid: (e.target as HTMLInputElement).checkValidity(), }); } init() { super.init(); onEvent( this.tree.element, "change", //"change input", ".contenteditable,input,textarea,select", // #61: we must not debounce the `change`, event.target may be reset to null // when the debounced handler is called. // (e) => { // this.debouncedOnChange(e); // } (e) => this._onChange(e) ); } /* Called by ext_keynav to pre-process input. */ _preprocessKeyEvent(data: any): boolean | undefined { const event = data.event; const eventName = eventToString(event); const tree = this.tree; const trigger = this.getPluginOption("trigger"); // const inputElem = // event.target && event.target.closest("input,[contenteditable]"); // tree.logDebug(`_preprocessKeyEvent: ${eventName}, editing:${this.isEditingTitle()}`); // --- Title editing: apply/discard --- // if (inputElem) { if (this.isEditingTitle()) { switch (eventName) { case "Enter": this._stopEditTitle(true, { event: event }); return false; case "Escape": this._stopEditTitle(false, { event: event }); return false; } // If the event target is an input element or `contenteditable="true"`, // we ignore it as navigation command return false; } // --- Trigger title editing if (tree.isRowNav() || tree.activeColIdx === 0) { switch (eventName) { case "Enter": if (trigger.indexOf("macEnter") >= 0 && isMac) { this.startEditTitle(); return false; } break; case "F2": if (trigger.indexOf("F2") >= 0) { this.startEditTitle(); return false; } break; } return true; } return true; } /** Return true if a title is currently being edited. */ isEditingTitle(node?: WunderbaumNode): boolean { return node ? this.curEditNode === node : !!this.curEditNode; } /** Start renaming, i.e. replace the title with an embedded `<input>`. */ startEditTitle(node?: WunderbaumNode | null) { node = node ?? this.tree.getActiveNode(); const validity = this.getPluginOption("validity"); const select = this.getPluginOption("select"); if (!node) { return; } if (node.isStatusNode()) { node.logWarn("Cannot edit status node."); return; } this.tree.logDebug(`startEditTitle(node=${node})`); let inputHtml = node._callEvent("edit.beforeEdit"); if (inputHtml === false) { node.logDebug("beforeEdit canceled operation."); return; } // `beforeEdit(e)` may return an input HTML string. Otherwise use a default // (we also treat a `true` return value as 'use default'): if (inputHtml === true || !inputHtml) { const title = escapeHtml(node.title); let opt = this.getPluginOption("maxlength"); const maxlength = opt ? ` maxlength="${opt}"` : ""; opt = this.getPluginOption("minlength"); const minlength = opt ? ` minlength="${opt}"` : ""; const required = opt > 0 ? " required" : ""; inputHtml = `<input type=text class="wb-input-edit" tabindex=-1 value="${title}" ` + `autocorrect="off"${required}${minlength}${maxlength} >`; } const titleSpan = node .getColElem(0)! .querySelector(".wb-title") as HTMLSpanElement; titleSpan.innerHTML = inputHtml; const inputElem = titleSpan.firstElementChild as HTMLInputElement; if (validity) { // Permanently apply input validations (CSS and tooltip) inputElem.addEventListener("keydown", (e) => { inputElem.setCustomValidity(""); if (!inputElem.reportValidity()) { node!.logWarn(`Invalid input: '${inputElem.value}'`); } }); } inputElem.focus(); if (select) { inputElem.select(); } this.curEditNode = node; node._callEvent("edit.edit", { inputElem: inputElem, }); } /** * * @param apply * @returns */ stopEditTitle(apply: boolean) { return this._stopEditTitle(apply, {}); } /* * * @param apply * @param opts.canKeepOpen */ _stopEditTitle(apply: boolean, options: any) { options ??= {}; const focusElem = document.activeElement as HTMLInputElement; let newValue = focusElem ? getValueFromElem(focusElem) : null; const node = this.curEditNode; const forceClose = !!options.forceClose; const validity = this.getPluginOption("validity"); if (newValue && this.getPluginOption("trim")) { newValue = newValue.trim(); } if (!node) { this.tree.logDebug("stopEditTitle: not in edit mode."); return; } node.logDebug(`stopEditTitle(${apply})`, options, focusElem, newValue); if (apply && newValue !== null && newValue !== node.title) { const errMsg = focusElem.validationMessage; if (errMsg) { // input element's native validation failed throw new Error( `Input validation failed for "${newValue}": ${errMsg}.` ); } const colElem = node.getColElem(0)!; this._applyChange("edit.apply", node, colElem, focusElem, { oldValue: node.title, newValue: newValue, inputElem: focusElem, inputValid: focusElem.checkValidity(), }).then((value) => { const errMsg = focusElem.validationMessage; if (validity && errMsg && value !== false) { // Handler called 'inputElem.setCustomValidity()' to signal error throw new Error( `Edit apply validation failed for "${newValue}": ${errMsg}.` ); } // Discard the embedded `<input>` // node.logDebug("applyChange:", value, forceClose) if (!forceClose && value === false) { // Keep open return; } node?.setTitle(newValue); // NOTE: At least on Safari, this render call triggers a scroll event // probably because the focused input is replaced. this.curEditNode?._render({ preventScroll: true }); this.curEditNode = null; this.relatedNode = null; this.tree.setFocus(); // restore focus that was in the input element }); // .catch((err) => { // node.logError(err); // }); // Trigger 'change' event for embedded `<input>` // focusElem.blur(); } else { // Discard the embedded `<input>` // NOTE: At least on Safari, this render call triggers a scroll event // probably because the focused input is replaced. this.curEditNode?._render({ preventScroll: true }); this.curEditNode = null; this.relatedNode = null; // We discarded the <input>, so we have to acquire keyboard focus again this.tree.setFocus(); } } /** * Create a new child or sibling node and start edit mode. */ createNode( mode: InsertNodeType = "after", node?: WunderbaumNode | null, init?: string | WbNodeData ) { const tree = this.tree; node = node ?? (tree.getActiveNode() as WunderbaumNode); assert(node, "No node was passed, or no node is currently active."); // const validity = this.getPluginOption("validity"); mode = mode || "prependChild"; if (init == null) { init = { title: "" }; } else if (typeof init === "string") { init = { title: init }; } else { assert(isPlainObject(init), `Expected a plain object: ${init}`); } // Make sure node is expanded (and loaded) in 'child' mode if ( (mode === "prependChild" || mode === "appendChild") && node?.isExpandable(true) ) { node.setExpanded().then(() => { this.createNode(mode, node, init); }); return; } const newNode = node.addNode(init, mode); newNode.setClass("wb-edit-new"); this.relatedNode = node; // Don't filter new nodes: newNode.match = -1; newNode.makeVisible({ noAnimation: true }).then(() => { this.startEditTitle(newNode); }); } }