diff --git a/client/components/ChatInput.vue b/client/components/ChatInput.vue
index c007d12e..612ff16b 100644
--- a/client/components/ChatInput.vue
+++ b/client/components/ChatInput.vue
@@ -1,18 +1,71 @@
+
+
diff --git a/client/components/SelectPopup.vue b/client/components/SelectPopup.vue
new file mode 100644
index 00000000..9092428a
--- /dev/null
+++ b/client/components/SelectPopup.vue
@@ -0,0 +1,182 @@
+
+
+
+
+
diff --git a/client/components/Windows/Help.vue b/client/components/Windows/Help.vue
index d367f39d..73181b1e 100644
--- a/client/components/Windows/Help.vue
+++ b/client/components/Windows/Help.vue
@@ -210,24 +210,10 @@
- Mark any text typed after this shortcut to be colored. After hitting this
- shortcut, enter an integer in the range
- 0—15
to select the desired color, or use the autocompletion
- menu to choose a color name (see below).
-
-
- Background color can be specified by putting a comma and another integer in
- the range 0—15
after the foreground color number
- (autocompletion works too).
-
-
- A color reference can be found
- here.
+ Open a color picker for coloring the currently selected text. You can select
+ a color with the mouse or arrow keys and apply the color by pressing
+ Enter. You can toggle between foreground and background color
+ with the buttons or by pressing T.
@@ -239,7 +225,7 @@
- Mark all text typed after this shortcut as
+ Format selected text as
bold.
@@ -252,7 +238,7 @@
- Mark all text typed after this shortcut as
+ Format selected text as
underlined.
@@ -265,7 +251,7 @@
- Mark all text typed after this shortcut as
+ Format selected text as
italics.
@@ -278,7 +264,7 @@
- Mark all text typed after this shortcut as
+ Format selected text as
struck through.
@@ -291,7 +277,7 @@
- Mark all text typed after this shortcut as
+ Format selected text as
monospaced.
@@ -304,8 +290,7 @@
- Mark all text typed after this shortcut to be reset to its original
- formatting.
+ Remove all formatting from selected text.
diff --git a/client/components/WysiwygInput.vue b/client/components/WysiwygInput.vue
new file mode 100644
index 00000000..bdd177ce
--- /dev/null
+++ b/client/components/WysiwygInput.vue
@@ -0,0 +1,435 @@
+
+
+
+
+
+
+
diff --git a/client/css/style.css b/client/css/style.css
index f1cb95e1..f6f49cc6 100644
--- a/client/css/style.css
+++ b/client/css/style.css
@@ -42,6 +42,7 @@
--upload-progressbar-color: var(--button-color);
}
+.wysiwyg-container .wysiwyg-input:empty::before,
::placeholder {
color: rgba(0, 0, 0, 0.35);
opacity: 1; /* fix opacity in Firefox */
@@ -284,13 +285,13 @@ p {
#viewport .lt::before,
#viewport .rt::before,
#chat button.menu::before,
+.toolbar button.format::before,
.channel-list-item::before,
#footer .icon,
#chat .count::before,
#settings .extra-help,
#settings #play::before,
-#form #upload::before,
-#form #submit::before,
+#form .chat-input-button::before,
#chat .msg[data-type="away"] .from::before,
#chat .msg[data-type="back"] .from::before,
#chat .msg[data-type="invite"] .from::before,
@@ -326,6 +327,7 @@ p {
.channel-list-item .not-connected-icon::before,
.channel-list-item .parted-channel-icon::before,
.jump-to-input::before,
+.colorpicker .tools button::before,
#sidebar .collapse-network-icon::before {
font: normal normal normal 14px/1 FontAwesome;
font-size: inherit; /* Can't have font-size inherit on line above, so need to override */
@@ -2143,7 +2145,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
margin: 5px;
padding: 0;
resize: none;
- flex: 1 0 auto;
align-self: center;
touch-action: pan-y;
}
@@ -2152,8 +2153,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
display: none;
}
-#form #upload,
-#form #submit {
+#form .chat-input-button {
color: #607992;
font-size: 14px;
height: 32px;
@@ -2161,8 +2161,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
flex: 0 0 auto;
}
-#form #upload[disabled],
-#form #submit[disabled] {
+#form .chat-input-button[disabled] {
opacity: 0.5;
}
@@ -2176,6 +2175,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
background: transparent;
}
+.floating-container,
#context-menu,
.textcomplete-menu {
position: absolute;
diff --git a/client/js/autocompletion.js b/client/js/autocompletion.js
index cc626fb7..8f62baa6 100644
--- a/client/js/autocompletion.js
+++ b/client/js/autocompletion.js
@@ -19,7 +19,8 @@ const emojiStrategy = {
// Trim colon from the matched term,
// as we are unable to get a clean string from match regex
term = term.replace(/:$/, "");
- callback(fuzzyGrep(term, emojiSearchTerms));
+ const res = fuzzyGrep(term, emojiSearchTerms);
+ callback(res);
},
template([string, original]) {
return `${emojiMap[original]} ${string}`;
diff --git a/client/js/helpers/positionElement.js b/client/js/helpers/positionElement.js
new file mode 100644
index 00000000..f7b17d52
--- /dev/null
+++ b/client/js/helpers/positionElement.js
@@ -0,0 +1,33 @@
+"use strict";
+
+// Set the absolute position of an element to x,y optionally anchoring
+// it by it's right and/or bottom edge (defaults to left, top)
+export default (element, x, y, hAnchor, vAnchor) => {
+ const elementWidth = element.offsetWidth;
+ const elementHeight = element.offsetHeight;
+
+ const offset = {
+ left: x - (hAnchor === "right" ? elementWidth : 0),
+ top: y - (vAnchor === "bottom" ? elementHeight : 0),
+ };
+
+ // If the offset would place the element out of viewport, move it back in
+ if (offset.left < 0) {
+ offset.left = 0;
+ }
+
+ if (offset.top < 0) {
+ offset.top = 0;
+ }
+
+ if (window.innerWidth - offset.left < elementWidth) {
+ offset.left = window.innerWidth - elementWidth;
+ }
+
+ if (window.innerHeight - offset.top < elementHeight) {
+ offset.top = window.innerHeight - elementHeight;
+ }
+
+ element.style.left = offset.left + "px";
+ element.style.top = offset.top + "px";
+};
diff --git a/client/js/helpers/wysiwyg.js b/client/js/helpers/wysiwyg.js
new file mode 100644
index 00000000..8efff45f
--- /dev/null
+++ b/client/js/helpers/wysiwyg.js
@@ -0,0 +1,113 @@
+"use strict";
+
+export function execCommandAndGetNewElements(command, container) {
+ const elementsBefore = Array.from(container.querySelectorAll("*"));
+ document.execCommand(command, false);
+ const elementsAfter = Array.from(container.querySelectorAll("*"));
+ const newElements = elementsAfter.filter((e) => !elementsBefore.includes(e));
+ return newElements;
+}
+
+export function cleanWysiwygMarkup(element) {
+ const elements = element.querySelectorAll("*");
+
+ elements.forEach((el) => {
+ // Remove any empty elements
+ if (!el.innerText.trim() && el.nodeName !== "BR") {
+ el.remove();
+ return;
+ }
+
+ // Only allow class attribute
+ cleanAttributes(el, ["class"]);
+ });
+}
+
+export function cleanAttributes(element, allowed) {
+ [...element.attributes].forEach((attr) => {
+ if (allowed.includes(attr.name)) {
+ return;
+ }
+
+ element.removeAttribute(attr.name);
+ });
+}
+
+export function getLinesAsFragments(element, range) {
+ // If element is empty return empty array
+ if (!element.childNodes.length) {
+ return [];
+ }
+
+ // Find all line breaks
+ const breaks = Array.from(element.querySelectorAll("br"));
+
+ if (!breaks.length) {
+ range.setStartBefore(element.firstChild);
+ range.setEndAfter(element.lastChild);
+ return [range.cloneContents()];
+ }
+
+ const fragments = [];
+
+ // Iterate over br tags and getting the ranges between them
+ let start = element.firstChild;
+
+ for (const br of breaks) {
+ range.setStartBefore(start);
+ range.setEndBefore(br);
+ fragments.push(range.cloneContents()); // Store the current line fragment
+ start = br.nextSibling ? br.nextSibling : br; // Move start to element after current br
+ }
+
+ // Get the final fragment
+ range.setStartBefore(start);
+ range.setEndAfter(element.lastChild);
+ fragments.push(range.cloneContents());
+
+ return fragments;
+}
+
+// Recursively clone a node tree and omit elements that
+// dont pass the test while keeping their children
+export function cloneNodeTreeSelective(from, omitTest) {
+ // Create a node tree to hold our cloned content
+ const fragment = document.createDocumentFragment();
+
+ for (const element of from.childNodes) {
+ if (element.nodeName === "#text") {
+ // Text nodes have no children so no need to do anything special
+ fragment.appendChild(element.cloneNode(true)); // Deep clone
+ } else {
+ // Create a fragment for the cloned children
+ let innerFragment = document.createDocumentFragment();
+
+ // Clone the children into the fragment
+ Array.from(element.childNodes).forEach((e) =>
+ innerFragment.appendChild(e.cloneNode(true))
+ );
+
+ // Run selective clone on the new fragment
+ innerFragment = cloneNodeTreeSelective(innerFragment, omitTest);
+
+ // If this element should be omitted, just append it's children
+ if (omitTest(element)) {
+ for (const innerElement of innerFragment.childNodes) {
+ fragment.appendChild(innerElement.cloneNode(true));
+ }
+ } else {
+ // Clone existing element withjaklsjd klas jasdlkj dklasdajd sakld aksdsdsada jaklsjd klas jasdlkj dklasdajd sakld aksout children
+ const newElement = element.cloneNode();
+
+ // Populate the new element with the cloned fragments
+ for (const innerElement of innerFragment.childNodes) {
+ newElement.appendChild(innerElement.cloneNode(true));
+ }
+
+ fragment.appendChild(newElement);
+ }
+ }
+ }
+
+ return fragment;
+}
diff --git a/client/js/keybinds.js b/client/js/keybinds.js
index daee796f..054d5bc7 100644
--- a/client/js/keybinds.js
+++ b/client/js/keybinds.js
@@ -185,7 +185,7 @@ document.addEventListener("keydown", (e) => {
return;
}
- const input = document.getElementById("input");
+ const input = document.getElementById("wysiwyg-input");
if (!input) {
return;
diff --git a/package.json b/package.json
index 5a8a938b..018000d3 100644
--- a/package.json
+++ b/package.json
@@ -50,6 +50,7 @@
"file-type": "14.1.4",
"filenamify": "4.1.0",
"got": "10.6.0",
+ "he": "1.2.0",
"irc-framework": "4.7.0",
"is-utf8": "0.2.1",
"ldapjs": "2.0.0-pre.5",