Skip to content

Commit

Permalink
Enhancements to Suggestion Feature and UI Updates (#23)
Browse files Browse the repository at this point in the history
* Optimize suggestion filtering and UI updates

Refactored suggestion logic in `DocumentComponents` to delegate suggestion retrieval to a new model `StaticDocumentModel`, improving separation of concerns and encapsulation. This change includes filtering and mapping logic adjustments to cater to case-insensitive input matching and expanding the maximally shown suggestions to 100, enhancing the usability of the suggestion feature.

Removed obsolete `datalist` HTML elements and related DOM interactions for suggestion handling in `TextComponent` and `TextareaComponent`. Introduced a new `SuggestionComponent` class to centralize suggestion UI handling, allowing for a more structured and maintainable approach to rendering suggestions. This change improves the extensibility and encapsulation of suggestion rendering.

Commented out presentation and input container toggling in `BaseComponent` which suggests a move towards a different UI update strategy, possibly transitioning towards a pure CSS-based show/hide logic for elements during editing mode. Further investigation or follow-up changes would be needed to clarify and finalize this approach.

Restructured SCSS by moving editable data view styles to a dedicated partial, `_editableDataView.scss`, organizing the styles better and facilitating easier maintenance. This cleanup helps with the segregation of styles making the stylesheets more manageable.

It is still pending:
- Refactorization of the SuggestionComponent
- Conversion of the other components,
- and adjustments to the BaseComponent to be able to handle both types of components.

* Use fallback for missing document description

Modified document description retrieval to use a null-coalescing operator, ensuring an empty string is assigned when the description is unavailable. This change prevents potential exceptions during document component rendering when the description field is absent in the data model.

* Enhanced suggestion logic and UI toggle

Refactored the suggestion component to improve the clarity and maintainability of the code by introducing a structured comment system that details the functionality, parameters, and expected behavior of the component's methods and properties. This facilitation of understanding not only benefits current maintenance but also aids future developers in onboarding.

The key behavioral change was a shift in the suggestion index logic. Instead of relying on a boolean flag paired with a last index variable, the new implementation uses a dedicated index and a scrolling mode flag. The functional benefits of this are twofold: it simplifies the state management within the component and makes the suggestion cycling behavior clearer and more predictable.

On the UI side, edit mode interaction was polished, ensuring that presentation and input container toggling occurs efficiently upon mode transitions. The removal of commented-out code results in cleaner and more readable codebase.

To support these enhancements, unnecessary suggestion list manipulation methods were purged from the TextComponent, streamlining the component's responsibilities and leaning on the improved SuggestionComponent to handle suggestion-related tasks wholly.

All these changes collectively foster a more robust, streamlined, and understandable component architecture, optimizing the responsiveness of the suggestion system and the UI's edit/view mode transition, resulting in an improved UX.

* Version bump to V0.0.34
  • Loading branch information
PxaMMaxP authored Jan 17, 2024
1 parent 04caf05 commit 6da4721
Show file tree
Hide file tree
Showing 11 changed files with 611 additions and 291 deletions.
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"id": "prj",
"name": "Prj Plugin",
"version": "0.0.33",
"version": "0.0.34",
"minAppVersion": "0.15.0",
"description": "Prj Plugin - Project, Document, and Task Management",
"author": "M. Passarello",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "obsidian-sample-plugin",
"version": "0.0.33",
"version": "0.0.34",
"description": "Prj Plugin - Project, Document, and Task Management",
"main": "main.js",
"scripts": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import Lng from "src/classes/Lng";
import EditableDataView from "src/libs/EditableDataView/EditableDataView";
import Helper from "src/libs/Helper";
import { DocumentModel } from "src/models/DocumentModel";
import { StaticDocumentModel } from "src/models/StaticHelper/StaticDocumentModel";

export default class DocumentComponents {
public static createCellSummary(
documentModel: DocumentModel,
component: Component,
summaryRelatedFiles: DocumentFragment) {
const description = documentModel.getDescription();
const description = documentModel.data.description ?? "";
new EditableDataView(summaryRelatedFiles, component)
.addTextarea(textarea => textarea
.setValue(description)
Expand Down Expand Up @@ -161,13 +162,10 @@ export default class DocumentComponents {
.setTitle(title)
.enableEditability()
.setSuggester((inputValue: string) => {
const suggestions = models
.flatMap(document => [document.data.sender, document.data.recipient])
.filter((v): v is string => v != null)
.filter((v, index, self) => self.indexOf(v) === index)
.filter(v => v.includes(inputValue))
.sort()
.splice(0, 10);
const suggestions = StaticDocumentModel.getAllSenderRecipients()
.filter(suggestion => suggestion.toLowerCase().includes(inputValue.toLowerCase()))
.slice(0, 100)
.map(suggestion => { return { value: suggestion, label: suggestion } });
return suggestions;
})
.onSave((newValue: string) => onSaveCallback(newValue))
Expand Down
1 change: 0 additions & 1 deletion src/libs/EditableDataView/Components/BaseComponent.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Component, Platform, setIcon } from "obsidian";
import Global from "src/classes/Global";


export default abstract class BaseComponent {
public get container(): HTMLDivElement {
return this.shippingContainer;
Expand Down
339 changes: 339 additions & 0 deletions src/libs/EditableDataView/Components/SuggestionComponent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,339 @@
import { Component, MarkdownRenderChild } from "obsidian";

/**
* A suggestion.
*/
export interface Suggestion {
value: string;
label: string;
}

/**
* A list of suggestions.
*/
export type Suggestions = Suggestion[];

/**
* A child for the suggestor.
* @remarks - This child is used to register the events for the suggestor.
* - After the suggestor is unloaded, the events are unregistered.
*/
export class SuggestorChild extends MarkdownRenderChild {
constructor(container: HTMLElement) {
super(container);
}

override onunload(): void {
super.onunload();
}
}

export default class SuggestionComponent {
private _component: Component;
private _suggester: ((value: string) => Suggestions) | undefined;

private suggestionsContainer: HTMLSpanElement;
private _inputElement: HTMLElement;

private _suggestions: Suggestions;
private _activeSuggestions: Suggestions;
private _suggestorChild: SuggestorChild | undefined;
private _suggestionIndex = 0;
private _scrollMode: boolean;

/**
* Creates a new instance of the suggestion component.
* @param inputElement The input element to register the suggestor to.
* @param component The component to register the suggestor to.
* @remarks - The input element should have a parent element, on which the suggestions container is appended.
*/
constructor(inputElement: HTMLElement, component: Component) {
this._inputElement = inputElement;
this._component = component;
}

/**
* Sets the suggestions.
* @param suggestions The suggestions to set.
* @returns The component itself.
* @remarks If the suggestions are set, a suggestor is not needed.
*/
public setSuggestions(suggestions: Suggestions) {
this._suggestions = suggestions;
return this;
}

/**
* Sets the suggester.
* @param suggester The suggester to set.
* @returns The component itself.
* @remarks If the suggester is set, the suggestions are not needed and will be ignored.
*/
public setSuggester(suggester: (value: string) => Suggestions) {
this._suggester = suggester;
return this;
}

private setSuggestion() {

let suggestion: Suggestion | undefined;
if (!this._scrollMode) {
// If the scroll mode is disabled, the first suggestion is shown.
// The first suggestion is the suggestion that starts with the text in the input element. (case insensitive)
suggestion = this._activeSuggestions.filter(suggestion => suggestion.value.toLowerCase().startsWith(this._inputElement.textContent?.toLowerCase() ?? '')).first();
this._suggestionIndex = 0;
} else {
// In scroll mode, the suggestion at the index is displayed. This index can be changed beforehand using the arrow buttons.
const index = this._suggestionIndex;
if (index < 0) {
suggestion = this._activeSuggestions.last();
this._suggestionIndex = this._activeSuggestions.length - 1;
} else if (index >= this._activeSuggestions.length) {
suggestion = this._activeSuggestions.first();
this._suggestionIndex = 0;
} else {
suggestion = this._activeSuggestions[index];
this._suggestionIndex = index;
}
}

if (suggestion) {
if (suggestion.value.toLowerCase().startsWith(this._inputElement.textContent?.toLowerCase() ?? '')) {
// If the suggestion starts with the text in the input element, the text in the input element is adopted.
const suggestionText = suggestion.value.slice(this.inputTextLength);
this.suggestionsContainer.innerText = suggestionText;
} else {
// If the suggestion does not start with the text in the input element, the text in the input element is replaced with the suggestion.
const length = this._inputElement.textContent?.length ?? 1;
this._inputElement.textContent = suggestion.value.slice(0, length);
this.suggestionsContainer.innerText = suggestion.value.slice(length);
this.setInputCursorAbsolutePosition(length);
}
}

if (this._activeSuggestions.length > 0) {
this.suggestionsContainer.style.display = '';
} else {
this.suggestionsContainer.style.display = 'none';
}
}

/**
* Refreshes the active suggestions.
*/
private refreshActiveSuggestions() {
this._activeSuggestions = this._suggester ? this._suggester(this._inputElement.textContent ?? '') : this._suggestions;
}

/**
* Enables the suggestor.
* @remarks Run this, if you want to enable the suggestor. (e.g. on enable edit mode)
* @remarks - The suggestor is a child of the component.
* - The suggestor is used to display the suggestions.
* - The suggestor has the css class `suggestions-container`.
* - The suggestor is loaded and registered to the input element.
*/
public enableSuggestior() {
this._suggestorChild = new SuggestorChild(this.suggestionsContainer);
this._suggestorChild.load();
this._component.addChild(this._suggestorChild);

// Set the cursor to the end of the input element.
this.setInputCursorAbsolutePosition(Number.MAX_SAFE_INTEGER);

this.buildSuggestionsContainer();

this._suggestorChild.registerDomEvent(this._inputElement, 'input', this.onInput.bind(this));
this._suggestorChild.registerDomEvent(this._inputElement, 'keydown', this.onKeydown.bind(this));
}

/**
* Handles the input event for the suggestion component.
* @remarks Disables the scroll mode, refreshes the active suggestions, and sets the suggestion.
*/
private onInput() {
// Disable the scroll mode.
this._scrollMode = false;

// Refresh the active suggestions and the shown suggestion.
this.refreshActiveSuggestions();
this.setSuggestion();
}

/**
* Handles the keydown event for the suggestion component.
* @param event The keyboard event.
* @remarks - The 'ArrowUp' and 'ArrowDown' buttons are used to scroll through the suggestions.
* - The 'ArrowLeft' and 'ArrowRight' buttons are used to move the cursor in the input element.
* If the cursor is at the end of the input element, the first character of the suggestions container is adopted.
* - The 'Tab' button is used to adopt the complete suggestion.
* - The 'Ctrl' + 'a' button is used to select the text in the input element.
*/
private onKeydown(event: KeyboardEvent) {

if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
// If the 'ArrowUp' or 'ArrowDown' button is pressed, the suggestions are scrolled through.
event.preventDefault();

this._scrollMode = true;
if (event.key === 'ArrowUp') {
this._suggestionIndex++;
} else if (event.key === 'ArrowDown') {
this._suggestionIndex--;
}
// Refresh the shown suggestion.
this.setSuggestion();
} else if (event.key === 'ArrowLeft') {
// If the 'ArrowLeft' button is pressed, the cursor is moved to the left.
event.preventDefault();

this.setInputCursorRelativePosition(-1);

this.refreshActiveSuggestions();
} else if (event.key === 'ArrowRight') {
// If the 'ArrowRight' button is pressed, the cursor is moved to the right.
event.preventDefault();

// If the cursor is at the end of the input element, the first character of the suggestions container is adopted.
this.adoptSuggestionCharacter() ?
this.setInputCursorAbsolutePosition(this.inputTextLength) :
this.setInputCursorRelativePosition(1);

this.refreshActiveSuggestions();
} else if (event.key === 'Tab') {
// If the 'Tab' button is pressed, the complete suggestion is adopted.
event.preventDefault();

this.adoptSuggestion();
} else if (event.ctrlKey && event.key === 'a') {
// If the 'Ctrl' + 'a' button is pressed, the text in the input element is selected.
event.preventDefault();

this.selectText(0, Number.MAX_SAFE_INTEGER);
}
}

/**
* Adopts the complete suggestion in the suggestions container.
*/
private adoptSuggestion() {
this._inputElement.textContent += this.suggestionsContainer.innerText;
const suggestion = this._activeSuggestions.find(suggestion => suggestion.value.toLowerCase().startsWith(this._inputElement.textContent?.toLowerCase() ?? ''))
this._inputElement.textContent = suggestion ? suggestion.value : this._inputElement.textContent;
this.suggestionsContainer.innerText = '';
this.setInputCursorAbsolutePosition(Number.MAX_SAFE_INTEGER);
}

/**
* Adopts the first character of the suggestions container if the cursor is at the end of the input element.
* @returns `true` if the character was adopted, otherwise `false`.
*/
private adoptSuggestionCharacter(): boolean {
if (this.cursorPosition === this.inputTextLength) {
this._inputElement.textContent += this.suggestionsContainer.innerText.slice(0, 1);
this.suggestionsContainer.innerText = this.suggestionsContainer.innerText.slice(1);
return true;
}
return false;
}

/**
* Returns the length of the text in the input element.
*/
private get inputTextLength() {
return this._inputElement.textContent?.length ?? 0;
}

/**
* Returns the current cursor position in the input element.
*/
private get cursorPosition() {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const currentRange = selection.getRangeAt(0);
return currentRange.endOffset;
}
return 0;
}

/**
* Sets the cursor position relative to the current cursor position.
* @param relativPosition Relative position to set the cursor to.
* @remarks The position is clamped to the length of the input element and minimum 0.
*/
private setInputCursorRelativePosition(relativPosition: number) {
this.setInputCursorAbsolutePosition(this.cursorPosition + relativPosition);
}

/**
* Sets the cursor position in the input element.
* @param position Position to set the cursor to.
* @remarks The position is clamped to the length of the input element.
*/
private setInputCursorAbsolutePosition(position: number) {
const selection = window.getSelection();
const range = document.createRange();

if (selection && selection.rangeCount > 0) {

const safePosition = Math.max(0, Math.min(position, this._inputElement.textContent?.length ?? 0));

if (this._inputElement.firstChild) {
range.setStart(this._inputElement.firstChild, safePosition);
range.setEnd(this._inputElement.firstChild, safePosition);
}

selection.removeAllRanges();
selection.addRange(range);
}
}

/**
* Selects the text in the input element.
* @param startPosition Start position of the selection.
* @param endPosition End position of the selection.
*/
private selectText(startPosition: number, endPosition: number) {
const selection = window.getSelection();
const range = document.createRange();

if (selection && this._inputElement.firstChild) {
const safeStartPosition = Math.max(0, Math.min(startPosition, this._inputElement.textContent?.length ?? 0));
const safeEndPosition = Math.max(0, Math.min(endPosition, this._inputElement.textContent?.length ?? 0));

range.setStart(this._inputElement.firstChild, safeStartPosition);
range.setEnd(this._inputElement.firstChild, safeEndPosition);

selection.removeAllRanges();
selection.addRange(range);
}
}

/**
* Builds the suggestions container.
* @remarks - The suggestions container is a span element that is appended to the parent of the input element.
* - The suggestions container is used to display the suggestions.
* - The suggestions container has a click event listener that sets the cursor to the end of the input element.
* - The suggestions container has the css classes `editable-data-view` & `suggestions-container`.
*/
private buildSuggestionsContainer() {
this.suggestionsContainer = document.createElement('span');
this.suggestionsContainer.classList.add('editable-data-view', 'suggestions-container');
this._inputElement.parentElement?.appendChild(this.suggestionsContainer);

// On click on the suggestions container, the cursor should be set to the end of the input element.
this._suggestorChild?.registerDomEvent(this.suggestionsContainer, 'click', () => {
this.setInputCursorAbsolutePosition(Number.MAX_SAFE_INTEGER);
});
}

/**
* Disables the suggestor.
* @remarks Run this, if you want to disable the suggestor. (e.g. on disable edit mode)
* @remarks Removes the suggestions container and unload the suggestor child.
*/
public disableSuggestor() {
this._suggestorChild?.unload();
this.suggestionsContainer.remove();
}
}
Loading

0 comments on commit 6da4721

Please sign in to comment.