Skip to content

Instantly share code, notes, and snippets.

@bohnacker
Last active August 11, 2023 08:30
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save bohnacker/187f2a38ba5716b617259bde1adfef10 to your computer and use it in GitHub Desktop.
Save bohnacker/187f2a38ba5716b617259bde1adfef10 to your computer and use it in GitHub Desktop.
Editable text field as a Svelte component, where words starting with # or @ gets highlighted while typing. If a list of tags or persons is given, autocompletion hints are given and may be accepted using tab.
<script>
import { tick, onMount } from "svelte";
export let readonly = true; // whether the text is readonly
export let text = ""; // text to be displayed
export let placeholder = ""; // placeholder text
export let highlightTag = true; // whether to highlight tags
export let tagColor = "DodgerBlue"; // color of tags
export let tagList = []; // list of tags for autocompletion
export let highlightPerson = true; // whether to highlight persons
export let personColor = "Tomato"; // color of persons
export let personList = []; // list of persons for autocompletion
export let style = ""; // style of the text field as a style string
export let autocomplete = true; // whether
export let tagListCurrent = new Set(); // list of tags that are currently in the text
export let personListCurrent = new Set(); // list of persons that are currently in the text
let textareaElement, html, showPlaceholder;
let rows = 1;
let completionActive = false;
let textBeforeCursor, completion, textAfterCursor;
tagList.sort();
personList.sort();
// init colors
let containerStyle, textareaStyle, htmlStyle;
initStyles();
// init html text
handleInput();
onMount(() => {
updateHtmlElementSize();
});
async function updateHtmlElementSize() {
await tick();
if (textareaElement) {
// get bounding client rect of textarea
let textareaRect = textareaElement.getBoundingClientRect();
// set height and width of html text to height and width of textarea
htmlStyle += "width:" + textareaRect.width + "px;";
htmlStyle += "height:" + textareaRect.height + "px;";
}
}
function escapeHtml(unsafe) {
if (!unsafe) return "";
return unsafe
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
// Prepare style given by the user
function initStyles() {
containerStyle = styleStringToObject(style);
containerStyle["position"] = "relative";
containerStyle = styleObjectToString(containerStyle);
// let backgroundColor = styleObject["background-color"];
// let color = styleObject["color"];
textareaStyle = styleStringToObject(style);
textareaStyle["caret-color"] =
textareaStyle["caret-color"] || textareaStyle["color"] || "black";
textareaStyle["color"] = "transparent";
textareaStyle["background-color"] = "transparent";
textareaStyle = styleObjectToString(textareaStyle);
htmlStyle = styleStringToObject(style);
htmlStyle["caret-color"] = "transparent";
htmlStyle = styleObjectToString(htmlStyle);
// console.log(textareaStyle);
// console.log(htmlStyle);
}
// Convert style string to object
function styleStringToObject(styleString) {
let styleAttributes = styleString.split(";").map((el) => {
let arr = el.split(":");
if (arr[0] && arr[1]) {
return [arr[0].trim(), arr[1].trim()];
} else {
return [];
}
});
let styleObject = Object.fromEntries(styleAttributes);
// if there was a semicolon at the end of the style string, the last entry will be undefined -> delete it
delete styleObject["undefined"];
return styleObject;
}
// Convert style object to string
function styleObjectToString(styleObj) {
let styleString = "";
for (const [key, value] of Object.entries(styleObj)) {
styleString += key + ":" + value + ";";
}
return styleString;
}
async function handleInput(event) {
// console.log(event)
if (completionActive && event.key == "Tab") event.preventDefault();
await tick();
if (completionActive && event.key == "Tab") {
text = textBeforeCursor + completion + textAfterCursor;
textareaElement.selectionStart = textBeforeCursor.length + completion.length;
textareaElement.selectionEnd = textBeforeCursor.length + completion.length;
} else {
// was necessary because of Chrome. text was not updated even after tick.
text = event ? event.target.value : text;
}
rows = text.split(/\r\n|\r|\n/).length;
showPlaceholder = text == "" || text.charCodeAt(0) == 10 ? true : false;
if (showPlaceholder) {
html = placeholder;
} else {
html = text;
// autocomplete tags and persons
completionActive = false;
let tagCompleted = false;
let personCompleted = false;
if (autocomplete) {
// prepare tag and person lists for autocompletion
let tagListAll = new Set([...tagList, ...tagListCurrent]);
let personListAll = new Set([...personList, ...personListCurrent]);
tagListAll = [...tagListAll].sort();
personListAll = [...personListAll].sort();
// console.log(tagListAll);
if (event && event?.type != "blur") {
const end = event.target.selectionEnd;
textBeforeCursor = text.substring(0, end);
textAfterCursor = text.substring(end);
let nextChar = text[end];
// console.log(textBeforeCursor, nextChar, textAfterCursor);
// only autocomplete if the next char is not a letter, number or a dash (so, something like a space, comma, ...)
if (!nextChar?.match(/[0-9A-Za-zÀ-ÖØ-öø-ÿ\-]/)) {
let lastTag = textBeforeCursor.match(/#([0-9A-Za-zÀ-ÖØ-öø-ÿ\-]+)$/);
let lastPerson = textBeforeCursor.match(/@([0-9A-Za-zÀ-ÖØ-öø-ÿ\-]+)$/);
// console.log(lastTag);
// console.log(lastPerson);
if (lastTag && highlightTag) {
let tag = lastTag[1];
let tagIndex = tagListAll.findIndex(
(el) =>
el.toLowerCase().startsWith(tag.toLowerCase()) &&
el.toLowerCase() != tag.toLowerCase()
);
// console.log(tagIndex);
if (tagIndex != -1) {
let newTag = tagListAll[tagIndex];
completion = newTag.substring(tag.length);
html = textBeforeCursor + "\x0E" + completion + "\x0F" + textAfterCursor;
tagCompleted = true;
completionActive = true;
}
} else if (lastPerson && highlightPerson) {
let person = lastPerson[1];
let personIndex = personListAll.findIndex(
(el) =>
el.toLowerCase().startsWith(person.toLowerCase()) &&
el.toLowerCase() != person.toLowerCase()
);
if (personIndex != -1) {
let newPerson = personListAll[personIndex];
completion = newPerson.substring(person.length);
html = textBeforeCursor + "\x0E" + completion + "\x0F" + textAfterCursor;
personCompleted = true;
completionActive = true;
}
}
}
}
}
// to make it possible to type chars like <, >, &, ", ...
html = escapeHtml(html);
html = html.replaceAll(" ", "&nbsp;");
html = html.replaceAll("\n", "<br>");
if (highlightTag) {
html = html.replace(
/#([0-9A-Za-zÀ-ÖØ-öø-ÿ\-]+)/g,
'<span style="color:' + tagColor + '">#$1</span>'
);
if (tagCompleted) {
html = html.replace(
/\x0E([0-9A-Za-zÀ-ÖØ-öø-ÿ\-]+)\x0F/g,
'<span style="opacity:0.5;color:' + tagColor + '">$1</span>'
);
}
}
if (highlightPerson) {
html = html.replace(
/@([0-9A-Za-zÀ-ÖØ-öø-ÿ\-]+)/g,
'<span style="color:' + personColor + '">@$1</span>'
);
if (personCompleted) {
html = html.replace(
/\x0E([0-9A-Za-zÀ-ÖØ-öø-ÿ\-]+)\x0F/g,
'<span style="opacity:0.5;color:' + personColor + '">$1</span>'
);
}
}
updateTagsAndPersons(text);
updateHtmlElementSize();
// console.log(text);
// console.log([...text].map((el) => el.charCodeAt(0)));
// console.log(html);
}
}
function handleBlur(event) {
completionActive = false;
handleInput(event);
// updateTagsAndPersons(text);
}
function updateTagsAndPersons(text) {
tagListCurrent.clear();
personListCurrent.clear();
let tags = [...text.matchAll(/#([0-9A-Za-zÀ-ÖØ-öø-ÿ\-]+)/g)];
let persons = [...text.matchAll(/@([0-9A-Za-zÀ-ÖØ-öø-ÿ\-]+)/g)];
// console.log(tags);
// console.log(persons);
tags.forEach((tag) => tagListCurrent.add(tag[1]));
persons.forEach((person) => personListCurrent.add(person[1]));
// console.log(tagListCurrent);
// console.log(personListCurrent);
}
</script>
<div class="container" style={""}>
<div id="html-text" class:placeholder={showPlaceholder} style={htmlStyle}>{@html html}</div>
<textarea
readonly={readonly}
bind:this={textareaElement}
id="plain-text"
name="textarea"
on:keydown={handleInput}
on:input={handleInput}
on:blur={handleBlur}
{rows}
bind:value={text}
style={textareaStyle}
/>
</div>
<style>
.container {
position: relative;
}
#plain-text {
position: relative;
background: transparent;
color: transparent;
caret-color: black;
width: 100%;
border: none;
resize: none;
z-index: 1;
}
#plain-text:focus {
outline: 1px solid blue;
}
#html-text {
position: absolute;
top: 0;
left: 0;
pointer-events: none;
background-color: transparent;
/* color: black; */
box-sizing: border-box;
z-index: 0;
}
#html-text.placeholder {
opacity: 0.2;
}
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment