Last active
August 11, 2023 08:30
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<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("&", "&") | |
.replaceAll("<", "<") | |
.replaceAll(">", ">") | |
.replaceAll('"', """); | |
} | |
// 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(" ", " "); | |
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