Skip to content
Snippets Groups Projects
Commit e0e57796 authored by Benedikt's avatar Benedikt
Browse files

improved auto suggestion input

parent 7bd796d5
No related branches found
No related tags found
No related merge requests found
Pipeline #51707 failed
// src/dsiUnitInput.js
import { DSIUnit } from "./dsiUnit.js";
import { dsiPrefixesHTML, dsiUnitsHTML, dsiKeyWords } from "./unitStrings.js";
......@@ -13,14 +14,13 @@ export class DSIUnitInput extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
// Internal state: store raw value
// Internal state: store raw value.
this._rawValue = "";
// Live update flag: if true, update suggestions and rendering on each keystroke.
this.liveUpdate = true;
// Allowed tokens (may be overridden via attribute)
this.allowedTokens = defaultAllowedTokens;
this.selectedSuggestionIndex = -1;
// Create our elements.
// Create elements.
this.input = document.createElement("input");
this.input.type = "text";
this.input.id = "dsiInput";
......@@ -31,20 +31,23 @@ export class DSIUnitInput extends HTMLElement {
this.display = document.createElement("div"); // For rendered output on blur.
this.display.id = "dsiDisplay";
this.display.style.cursor = "text"; // Make it clickable for editing.
// Wrap in a container (position: relative for suggestions).
// Container for input and suggestions.
const container = document.createElement("div");
container.style.position = "relative";
container.appendChild(this.input);
container.appendChild(this.suggestions);
container.appendChild(this.display);
// Append styles into the shadow DOM.
// Append styles.
const style = document.createElement("style");
style.textContent = `
#dsiInput {
width: 400px;
font-family: monospace;
padding: 4px;
box-sizing: border-box;
}
.autocomplete-list {
position: absolute;
......@@ -59,17 +62,23 @@ export class DSIUnitInput extends HTMLElement {
padding: 4px;
cursor: pointer;
}
.autocomplete-item:hover {
background-color: #eee;
.autocomplete-item.selected {
background-color: #bde4ff;
}
#dsiDisplay {
margin-top: 5px;
padding: 4px;
border: 1px solid #ccc;
display: none;
width: 400px;
font-family: monospace;
}
`;
this.shadowRoot.append(style, container);
// Bind event handlers.
this.onInput = this.onInput.bind(this);
this.onKeyDown = this.onKeyDown.bind(this);
this.onBlur = this.onBlur.bind(this);
this.onFocus = this.onFocus.bind(this);
}
......@@ -89,25 +98,53 @@ export class DSIUnitInput extends HTMLElement {
}
connectedCallback() {
// Check for external suggestions list.
const suggestionsAttr = this.getAttribute("suggestions-list");
if (suggestionsAttr) {
this.allowedTokens = suggestionsAttr.split(",").map(s => s.trim());
}
this.input.addEventListener("input", this.onInput);
this.input.addEventListener("keydown", this.onKeyDown);
this.input.addEventListener("blur", this.onBlur);
this.input.addEventListener("focus", this.onFocus);
this.display.addEventListener("click", this.onFocus);
}
disconnectedCallback() {
this.input.removeEventListener("input", this.onInput);
this.input.removeEventListener("keydown", this.onKeyDown);
this.input.removeEventListener("blur", this.onBlur);
this.input.removeEventListener("focus", this.onFocus);
this.display.removeEventListener("click", this.onFocus);
}
// Determine contextual suggestions based on previous complete token.
getContextualSuggestions() {
const value = this.input.value;
const tokens = value.split("\\").filter(Boolean);
if (tokens.length <= 1) {
return this.allowedTokens;
}
const prevToken = tokens[tokens.length - 2];
if (prevToken in dsiPrefixesHTML) {
// After a prefix, suggest only allowed units.
return Object.keys(dsiUnitsHTML);
} else if (prevToken in dsiUnitsHTML) {
// After a unit, suggest all allowed tokens.
// If "\per" exists in the value, omit "per".
if (value.includes("\\per")) {
return this.allowedTokens.filter(token => token !== "per");
}
return this.allowedTokens;
} else if (prevToken === "per" || prevToken === "tothe") {
return this.allowedTokens;
} else {
return this.allowedTokens;
}
}
onInput(e) {
// Store the raw value.
this._rawValue = this.input.value;
this.selectedSuggestionIndex = -1;
this.updateSuggestions();
if (this.liveUpdate) {
this.renderOutput();
......@@ -117,19 +154,29 @@ export class DSIUnitInput extends HTMLElement {
updateSuggestions() {
const cursorPos = this.input.selectionStart;
const value = this.input.value;
// Check: if caret is inside the braces of a "tothe" token, do not show suggestions.
const regexTothe = /\\tothe\{([^}]*)$/;
const substring = value.substring(0, cursorPos);
// Look for a pattern: a backslash followed by at least one letter at the end.
const regex = /\\([a-zA-Z]+)$/;
const matchTothe = substring.match(regexTothe);
if (matchTothe) {
this.suggestions.style.display = "none";
return;
}
// Use regex to capture current token (may be empty).
const regex = /\\([a-zA-Z]*)$/;
const match = substring.match(regex);
let currentToken = "";
if (match) {
const currentToken = match[1];
const suggestions = this.allowedTokens.filter(token =>
token.startsWith(currentToken)
);
this.showSuggestions(suggestions, substring.length - currentToken.length);
} else {
this.suggestions.style.display = "none";
currentToken = match[1]; // might be empty
}
// Determine suggestions based on context.
const contextSuggestions = this.getContextualSuggestions();
const suggestions = currentToken === ""
? contextSuggestions
: contextSuggestions.filter(token => token.startsWith(currentToken));
this.selectedSuggestionIndex = suggestions.length > 0 ? 0 : -1;
this.showSuggestions(suggestions, value.length - currentToken.length);
}
showSuggestions(list, tokenStartIndex) {
......@@ -138,30 +185,81 @@ export class DSIUnitInput extends HTMLElement {
this.suggestions.style.display = "none";
return;
}
list.forEach(token => {
list.forEach((token, index) => {
const item = document.createElement("div");
item.textContent = "\\" + token;
item.className = "autocomplete-item";
if (index === this.selectedSuggestionIndex) {
item.classList.add("selected");
}
item.addEventListener("mousedown", (e) => {
e.preventDefault();
const value = this.input.value;
const before = value.substring(0, tokenStartIndex);
const after = value.substring(this.input.selectionStart);
this.input.value = before + "\\" + token + after;
this._rawValue = this.input.value;
this.suggestions.style.display = "none";
this.input.focus();
this.renderOutput();
this.acceptSuggestion(token, tokenStartIndex);
});
this.suggestions.appendChild(item);
});
this.suggestions.style.display = "block";
}
onKeyDown(e) {
if (this.suggestions.style.display !== "none") {
const items = this.suggestions.querySelectorAll(".autocomplete-item");
if (e.key === "ArrowDown") {
e.preventDefault();
if (items.length > 0) {
this.selectedSuggestionIndex = (this.selectedSuggestionIndex + 1) % items.length;
this.updateSuggestionSelection();
}
} else if (e.key === "ArrowUp") {
e.preventDefault();
if (items.length > 0) {
this.selectedSuggestionIndex = (this.selectedSuggestionIndex - 1 + items.length) % items.length;
this.updateSuggestionSelection();
}
} else if (["Enter", "Tab", "ArrowRight"].includes(e.key)) {
if (this.selectedSuggestionIndex !== -1) {
e.preventDefault();
const selectedItem = items[this.selectedSuggestionIndex];
const token = selectedItem.textContent.slice(1); // remove backslash
const tokenStartIndex = this.input.value.lastIndexOf("\\");
this.acceptSuggestion(token, tokenStartIndex);
}
}
}
}
updateSuggestionSelection() {
const items = this.suggestions.querySelectorAll(".autocomplete-item");
items.forEach((item, idx) => {
if (idx === this.selectedSuggestionIndex) {
item.classList.add("selected");
} else {
item.classList.remove("selected");
}
});
}
acceptSuggestion(token, tokenStartIndex) {
const value = this.input.value;
const before = value.substring(0, tokenStartIndex);
const after = value.substring(this.input.selectionStart);
if (token === "tothe") {
// Append {} and place caret inside.
this.input.value = before + "\\" + token + "{}" + after;
this._rawValue = this.input.value;
const newPos = before.length + token.length + 2;
this.input.setSelectionRange(newPos, newPos);
} else {
this.input.value = before + "\\" + token + after;
this._rawValue = this.input.value;
}
this.suggestions.style.display = "none";
this.renderOutput();
}
renderOutput() {
try {
const unit = new DSIUnit(this.input.value);
// Display the rendered output using the DSIUnit's toHTML().
this.display.innerHTML = unit.toHTML();
if (unit.warnings && unit.warnings.length > 0) {
this.display.title = unit.warnings.join("; ");
......@@ -174,7 +272,6 @@ export class DSIUnitInput extends HTMLElement {
}
onBlur(e) {
// On blur, hide the input and show the rendered output.
this.renderOutput();
this.input.style.display = "none";
this.display.style.display = "block";
......@@ -182,18 +279,17 @@ export class DSIUnitInput extends HTMLElement {
}
onFocus(e) {
// On focus, restore the raw value for editing.
this.input.style.display = "block";
this.display.style.display = "none";
this.input.value = this._rawValue;
this.input.focus();
this.input.setSelectionRange(this.input.value.length, this.input.value.length);
}
// Expose the current raw value.
get value() {
return this._rawValue;
}
// Allow setting live update mode.
set live(value) {
this.liveUpdate = Boolean(value);
}
......
......@@ -89,6 +89,9 @@
display: block;
margin-bottom: 40px;
}
.autocomplete-item.selected {
background-color: #bde4ff;
}
</style>
</head>
<body>
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment