Wysiwyg WIP.

This commit is contained in:
Richard Lewis 2020-03-19 21:20:45 +02:00
parent db807d0c56
commit ffb6d811fa
12 changed files with 1272 additions and 258 deletions

View File

@ -1,19 +1,71 @@
<template>
<form id="form" method="post" action="" @submit.prevent="onSubmit">
<div class="toolbar-container" :class="{opened: showToolbar}">
<div class="toolbar">
<button
class="format format-b"
type="button"
aria-label="Bold"
@click="applyFormatting('bold')"
></button>
<button
class="format format-u"
type="button"
aria-label="Underline"
@click="applyFormatting('underline')"
></button>
<button
class="format format-i"
type="button"
aria-label="Italic"
@click="applyFormatting('italic')"
></button>
<button
class="format format-s"
type="button"
aria-label="Strikethrough"
@click="applyFormatting('strikeThrough')"
></button>
<button
class="format format-m"
type="button"
aria-label="Monospace"
@click="applyFormatting('monospace')"
></button>
<button
class="format format-c"
type="button"
aria-label="Color"
@click="applyFormatting('color')"
></button>
<button
class="format format-o"
type="button"
aria-label="Clear formatting"
@click="applyFormatting('removeFormat')"
></button>
</div>
</div>
<span id="upload-progressbar" />
<span id="nick">{{ network.nick }}</span>
<textarea
id="input"
ref="input"
dir="auto"
class="mousetrap"
enterkeyhint="send"
:value="channel.pendingMessage"
<WysiwygInput
ref="wysiwyg"
:placeholder="getInputPlaceholder(channel)"
:aria-label="getInputPlaceholder(channel)"
@input="setPendingMessage"
@keypress.enter.exact.prevent="onSubmit"
@submit="onSubmit"
/>
<span
id="format-tooltip"
class="tooltipped tooltipped-w tooltipped-no-touch"
aria-label="Text formatting"
>
<button
id="format"
type="button"
class="chat-input-button"
aria-label="Text formatting"
@click="toggleToolbar"
/>
</span>
<span
v-if="$store.state.serverConfiguration.fileUpload"
id="upload-tooltip"
@ -26,12 +78,14 @@
ref="uploadInput"
type="file"
aria-labelledby="upload"
class="chat-input-button"
multiple
@change="onUploadInputChange"
/>
<button
id="upload"
type="button"
class="chat-input-button"
aria-label="Upload file"
:disabled="!$store.state.isConnected"
/>
@ -44,6 +98,7 @@
<button
id="submit"
type="submit"
class="chat-input-button"
aria-label="Send message"
:disabled="!$store.state.isConnected"
/>
@ -51,87 +106,114 @@
</form>
</template>
<style>
.toolbar button.format-b::before {
content: "\f032"; /* https://fontawesome.io/icon/bold/ */
}
.toolbar button.format-u::before {
content: "\f0cd"; /* https://fontawesome.io/icon/underline/ */
}
.toolbar button.format-i::before {
content: "\f033"; /* https://fontawesome.io/icon/italic/ */
}
.toolbar button.format-s::before {
content: "\f0cc"; /* https://fontawesome.io/icon/strikethrough/ */
}
.toolbar button.format-m::before {
content: "\f121"; /* https://fontawesome.io/icon/code/ */
}
.toolbar button.format-c::before {
content: "\f53f"; /* https://fontawesome.com/icons/palette?style=solid */
}
.toolbar button.format-o::before {
content: "\f87d"; /* https://fontawesome.com/icons/remove-format?style=solid */
}
#form #format::before {
content: "\f031"; /* https://fontawesome.io/icons/font/ */
}
#form .toolbar-container {
position: absolute;
top: -70px;
left: 0;
right: 0;
pointer-events: none;
display: flex;
justify-content: center;
}
#form .toolbar-container.opened {
display: flex;
}
#form .toolbar-container .toolbar {
display: flex;
background: var(--body-bg-color);
color: var(--button-color);
padding: 0 6px;
border-radius: 5px;
opacity: 0;
transition: opacity 0.2s;
}
#form .toolbar-container.opened .toolbar {
pointer-events: all;
opacity: 1;
}
#form .toolbar-container .toolbar button {
padding: 10px 9px;
}
</style>
<script>
import Mousetrap from "mousetrap";
import {wrapCursor} from "undate";
import autocompletion from "../js/autocompletion";
import commands from "../js/commands/index";
// import autocompletion from "../js/autocompletion"; TODO
// import commands from "../js/commands/index"; TODO
import socket from "../js/socket";
import upload from "../js/upload";
import eventbus from "../js/eventbus";
const formattingHotkeys = {
"mod+k": "\x03",
"mod+b": "\x02",
"mod+u": "\x1F",
"mod+i": "\x1D",
"mod+o": "\x0F",
"mod+s": "\x1e",
"mod+m": "\x11",
};
// Autocomplete bracket and quote characters like in a modern IDE
// For example, select `text`, press `[` key, and it becomes `[text]`
const bracketWraps = {
'"': '"',
"'": "'",
"(": ")",
"<": ">",
"[": "]",
"{": "}",
"*": "*",
"`": "`",
"~": "~",
_: "_",
};
import WysiwygInput from "./WysiwygInput.vue";
let autocompletionRef = null;
export default {
name: "ChatInput",
components: {
WysiwygInput,
},
props: {
network: Object,
channel: Object,
},
data() {
return {
showToolbar: false,
};
},
watch: {
"channel.id"() {
if (autocompletionRef) {
autocompletionRef.hide();
}
this.closeToolbar();
},
"channel.pendingMessage"() {
this.setInputSize();
this.closeToolbar();
},
},
mounted() {
eventbus.on("escapekey", this.blurInput);
/* TODO
if (this.$store.state.settings.autocomplete) {
autocompletionRef = autocompletion(this.$refs.input);
}
*/
/* TODO
const inputTrap = Mousetrap(this.$refs.input);
inputTrap.bind(Object.keys(formattingHotkeys), function (e, key) {
const modifier = formattingHotkeys[key];
wrapCursor(
e.target,
modifier,
e.target.selectionStart === e.target.selectionEnd ? "" : modifier
);
return false;
});
inputTrap.bind(Object.keys(bracketWraps), function (e, key) {
if (e.target.selectionStart !== e.target.selectionEnd) {
wrapCursor(e.target, key, bracketWraps[key]);
return false;
}
});
inputTrap.bind(["up", "down"], (e, key) => {
if (
this.$store.state.isAutoCompleting ||
@ -160,6 +242,7 @@ export default {
return false;
});
*/
if (this.$store.state.serverConfiguration.fileUpload) {
upload.mounted();
@ -181,22 +264,6 @@ export default {
this.channel.inputHistoryPosition = 0;
this.setInputSize();
},
setInputSize() {
this.$nextTick(() => {
const style = window.getComputedStyle(this.$refs.input);
const lineHeight = parseFloat(style.lineHeight, 10) || 1;
// Start by resetting height before computing as scrollHeight does not
// decrease when deleting characters
this.$refs.input.style.height = "";
// Use scrollHeight to calculate how many lines there are in input, and ceil the value
// because some browsers tend to incorrently round the values when using high density
// displays or using page zoom feature
this.$refs.input.style.height =
Math.ceil(this.$refs.input.scrollHeight / lineHeight) * lineHeight + "px";
});
},
getInputPlaceholder(channel) {
if (channel.type === "channel" || channel.type === "query") {
return `Write to ${channel.name}`;
@ -204,6 +271,25 @@ export default {
return "";
},
onSubmit(e) {
e.preventDefault();
if (!this.$store.state.isConnected) {
return false;
}
const target = this.channel.id;
// const content = this.$refs.wysiwyg.getHtmlContent(); TODO: use this for input history
const lines = this.$refs.wysiwyg.getIrcLines();
this.$refs.wysiwyg.clear();
this.$refs.wysiwyg.focus();
const message = lines.join("\n");
socket.emit("input", {target, text: message});
},
/*
onSubmit() {
// Triggering click event opens the virtual keyboard on mobile
// This can only be called from another interactive event (e.g. button click)
@ -254,6 +340,7 @@ export default {
socket.emit("input", {target, text});
},
*/
onUploadInputChange() {
const files = Array.from(this.$refs.uploadInput.files);
upload.triggerUpload(files);
@ -263,7 +350,20 @@ export default {
this.$refs.uploadInput.click();
},
blurInput() {
this.$refs.input.blur();
this.$refs.wysiwyg.blur();
},
closeToolbar() {
this.showToolbar = false;
},
toggleToolbar() {
this.showToolbar = !this.showToolbar;
this.$refs.wysiwyg.focus();
},
applyFormatting(command) {
this.$refs.wysiwyg.runCommand(command);
this.closeToolbar();
this.$refs.wysiwyg.focus();
return false;
},
},
};

View File

@ -1,79 +1,43 @@
<template>
<div
v-if="isOpen"
id="context-menu-container"
@click="containerClick"
@contextmenu.prevent="containerClick"
@keydown.exact.up.prevent="navigateMenu(-1)"
@keydown.exact.down.prevent="navigateMenu(1)"
@keydown.exact.tab.prevent="navigateMenu(1)"
@keydown.shift.tab.prevent="navigateMenu(-1)"
>
<ul
id="context-menu"
ref="contextMenu"
role="menu"
:style="style"
tabindex="-1"
@mouseleave="activeItem = -1"
@keydown.enter.prevent="clickActiveItem"
>
<template v-for="(item, id) of items">
<li
:key="item.name"
:class="[
'context-menu-' + item.type,
item.class ? 'context-menu-' + item.class : null,
{active: id === activeItem},
]"
role="menuitem"
@mouseenter="hoverItem(id)"
@click="clickItem(item)"
>
{{ item.label }}
</li>
</template>
</ul>
</div>
<SelectPopup ref="select" />
</template>
<script>
import SelectPopup from "./SelectPopup.vue";
import {generateUserContextMenu, generateChannelContextMenu} from "../js/helpers/contextMenu.js";
import eventbus from "../js/eventbus";
export default {
name: "ContextMenu",
components: {
SelectPopup,
},
props: {
message: Object,
},
data() {
return {
isOpen: false,
previousActiveElement: null,
items: [],
activeItem: -1,
style: {
left: 0,
top: 0,
},
};
},
mounted() {
eventbus.on("escapekey", this.close);
eventbus.on("contextmenu:user", this.openUserContextMenu);
eventbus.on("contextmenu:channel", this.openChannelContextMenu);
eventbus.on("contextmenu:custom", this.openCustomContextMenu);
},
destroyed() {
eventbus.off("escapekey", this.close);
eventbus.off("contextmenu:user", this.openUserContextMenu);
eventbus.off("contextmenu:channel", this.openChannelContextMenu);
eventbus.off("contextmenu:custom", this.openCustomContextMenu);
this.close();
if (this.$refs.select) {
this.$refs.select.close();
}
},
methods: {
openCustomContextMenu(data) {
this.$refs.select.open(data.event, data.items);
},
openChannelContextMenu(data) {
const items = generateChannelContextMenu(this.$root, data.channel, data.network);
this.open(data.event, items);
this.$refs.select.open(data.event, items);
},
openUserContextMenu(data) {
const {network, channel} = this.$store.state.activeChannel;
@ -87,104 +51,8 @@ export default {
modes: [],
}
);
this.open(data.event, items);
},
open(event, items) {
event.preventDefault();
this.previousActiveElement = document.activeElement;
this.items = items;
this.activeItem = 0;
this.isOpen = true;
// Position the menu and set the focus on the first item after it's size has updated
this.$nextTick(() => {
const pos = this.positionContextMenu(event);
this.style.left = pos.left + "px";
this.style.top = pos.top + "px";
this.$refs.contextMenu.focus();
});
},
close() {
if (!this.isOpen) {
return;
}
this.isOpen = false;
this.items = [];
if (this.previousActiveElement) {
this.previousActiveElement.focus();
this.previousActiveElement = null;
}
},
hoverItem(id) {
this.activeItem = id;
},
clickItem(item) {
this.close();
if (item.action) {
item.action();
} else if (item.link) {
this.$router.push(item.link);
}
},
clickActiveItem() {
if (this.items[this.activeItem]) {
this.clickItem(this.items[this.activeItem]);
}
},
navigateMenu(direction) {
let currentIndex = this.activeItem;
currentIndex += direction;
const nextItem = this.items[currentIndex];
// If the next item we would select is a divider, skip over it
if (nextItem && nextItem.type === "divider") {
currentIndex += direction;
}
if (currentIndex < 0) {
currentIndex += this.items.length;
}
if (currentIndex > this.items.length - 1) {
currentIndex -= this.items.length;
}
this.activeItem = currentIndex;
},
containerClick(event) {
if (event.currentTarget === event.target) {
this.close();
}
},
positionContextMenu(event) {
const element = event.target;
const menuWidth = this.$refs.contextMenu.offsetWidth;
const menuHeight = this.$refs.contextMenu.offsetHeight;
if (element && element.classList.contains("menu")) {
return {
left: element.getBoundingClientRect().left - (menuWidth - element.offsetWidth),
top: element.getBoundingClientRect().top + element.offsetHeight,
};
}
const offset = {left: event.pageX, top: event.pageY};
if (window.innerWidth - offset.left < menuWidth) {
offset.left = window.innerWidth - menuWidth;
}
if (window.innerHeight - offset.top < menuHeight) {
offset.top = window.innerHeight - menuHeight;
}
return offset;
this.$refs.select.open(data.event, items);
},
},
};

View File

@ -0,0 +1,296 @@
<template>
<div v-if="isOpen" class="colorpicker-container overlay" @click="containerClick">
<div
id="context-menu"
ref="colorPicker"
class="colorpicker floating-container"
tabindex="-1"
@keydown.exact.up.stop.prevent="navigate('up')"
@keydown.exact.down.stop.prevent="navigate('down')"
@keydown.exact.left.stop.prevent="navigate('left')"
@keydown.exact.right.stop.prevent="navigate('right')"
@keydown.exact.84.stop.prevent="toggleMode()"
@keydown.enter.prevent="submit"
>
<div class="colorgrid">
<section>
<div
v-for="id in baseColorIds"
:key="id"
:class="['color', 'irc-bg' + id, {active: id === selectedColors[mode]}]"
@click="selectColor(id)"
></div>
</section>
<section>
<div
v-for="id in extendedColorIds"
:key="id"
:class="['color', 'irc-bg' + id, {active: id === selectedColors[mode]}]"
@click="selectColor(id)"
></div>
</section>
</div>
<hr />
<div class="tools">
<div class="mode">
<button
:class="['btn', 'color-mode-fg', {'btn-cancel': mode !== 'fg'}]"
title="Text color"
@click.stop.prevent="setMode('fg')"
></button>
<button
:class="['btn', 'color-mode-bg', {'btn-cancel': mode !== 'bg'}]"
title="Highlight color"
@click.stop.prevent="setMode('bg')"
></button>
</div>
<div class="preview">
<span
:class="[
'color',
selectedColors['fg'] ? 'irc-fg' + selectedColors['fg'] : null,
selectedColors['bg'] ? 'irc-bg' + selectedColors['bg'] : null,
]"
>preview</span
>
</div>
<div class="submit">
<button class="btn submit" @click.stop.prevent="submit"></button>
</div>
</div>
</div>
</div>
</template>
<style>
.overlay {
position: fixed;
z-index: 1;
top: 0;
right: 0;
bottom: 0;
left: 0;
}
.colorpicker {
padding: 6px !important;
}
.colorpicker .colorgrid {
display: flex;
flex-direction: column;
flex-wrap: wrap;
}
.colorpicker .colorgrid section {
display: grid;
grid-template-columns: repeat(12, auto);
}
.colorpicker .colorgrid .color {
display: inline-block;
width: 20px;
height: 20px;
overflow: hidden;
flex-shrink: 0;
border-radius: 3px;
margin: 1px;
border: 1px solid rgba(0, 0, 0, 0.05);
cursor: pointer;
}
.colorpicker .color.active {
transform: scale(1.6);
box-shadow: 0 3px 12px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(255, 255, 255, 0.8);
}
.colorpicker .colorgrid .irc-bg99 {
position: relative;
background: #fff;
}
.colorpicker .colorgrid .irc-bg99::before,
.colorpicker .colorgrid .irc-bg99::after {
content: "";
display: block;
width: 30px;
border-top: 1px solid #ccc;
transform-origin: left top;
transform: rotate(45deg);
}
.colorpicker .colorgrid .irc-bg99::after {
transform-origin: left top;
position: absolute;
bottom: 0;
transform: rotate(-45deg);
}
.colorpicker .tools {
margin-top: 10px;
padding: 10px 0 4px 0;
border-top: 1px solid #eee;
display: flex;
justify-content: space-between;
}
.colorpicker .tools div.mode,
.colorpicker .tools div.submit {
width: 30%;
}
.colorpicker .tools div.submit {
text-align: right;
}
.colorpicker .tools .preview span {
display: inline-block;
padding: 2px 4px;
border-radius: 3px;
}
.colorpicker .tools button {
margin: 0;
padding: 3px 9px;
}
.colorpicker .tools button.btn-cancel {
border-color: transparent;
}
.colorpicker .tools .color-mode-fg::before {
content: "\f031"; /* https://fontawesome.com/icons/font?style=solid */
}
.colorpicker .tools .color-mode-bg::before {
content: "\f591"; /* https://fontawesome.com/icons/highlighter?style=solid */
}
.colorpicker .tools button.submit::before {
content: "\f00c"; /* https://fontawesome.com/icons/check?style=solid */
}
</style>
<script>
import positionElement from "../js/helpers/positionElement";
export default {
name: "IrcColorPicker",
data() {
return {
isOpen: false,
mode: "fg",
selectedColors: {
fg: null,
bg: 4,
},
baseColorIds: Array.from({length: 16}, (v, k) => k), // 0 - 15
extendedColorIds: Array.from({length: 84}, (v, k) => k + 16), // 16 - 99
};
},
mounted() {
this.$root.$on("escapekey", this.close);
},
destroyed() {
this.$root.$off("escapekey", this.close);
this.close();
},
methods: {
open(pos, callback) {
this.isOpen = true;
this.callback = callback ? callback : null;
this.previousActiveElement = document.activeElement;
// Position the menu and set the focus on the first item after it's size has updated
this.$nextTick(() => {
this.$refs.colorPicker.focus();
positionElement(this.$refs.colorPicker, pos.x, pos.y, "left", "bottom");
});
},
close() {
// TODO: reset colors to null?
this.mode = "fg";
this.isOpen = false;
this.callback = null;
if (this.previousActiveElement) {
this.previousActiveElement.focus();
this.previousActiveElement = null;
}
},
escape() {
if (this.callback) {
this.callback(false);
}
this.close();
},
selectColor(id) {
this.selectedColors[this.mode] = id;
},
submit() {
if (this.callback) {
// Color 99 means no color, so pass null
this.callback({
fg: this.selectedColors.fg === 99 ? null : this.selectedColors.fg,
bg: this.selectedColors.bg === 99 ? null : this.selectedColors.bg,
});
}
this.close();
},
setMode(mode) {
this.mode = mode;
},
toggleMode() {
this.mode = this.mode === "fg" ? "bg" : "fg";
},
containerClick(event) {
if (event.currentTarget === event.target) {
this.close();
}
},
navigate(direction) {
const directionToDelta = {
up: -12,
down: 12,
left: -1,
right: 1,
};
let delta = directionToDelta[direction];
let color = this.selectedColors[this.mode];
// Improve vertical navigation between normal and extended
// colors as well as warping around between top and bottom
if (direction === "up") {
if (color < 16) {
delta = color < 12 ? -11 : -12;
} else if (color >= 16 && color <= 27) {
delta = color <= 19 ? -4 : -16;
}
} else if (direction === "down") {
if (color >= 88) {
delta = 11;
} else if (color >= 4 && color <= 15) {
delta = color >= 12 ? 4 : 16;
}
}
const lastColor = this.extendedColorIds[this.extendedColorIds.length - 1];
color += delta;
if (color > lastColor) {
color = color - lastColor;
}
if (color < 0) {
color = lastColor - Math.abs(color);
}
this.selectedColors[this.mode] = color;
},
},
};
</script>

View File

@ -0,0 +1,182 @@
<template>
<div
v-if="isOpen"
id="context-menu-container"
@click="containerClick"
@contextmenu.prevent="containerClick"
@keydown.exact.up.prevent="navigateMenu(-1)"
@keydown.exact.down.prevent="navigateMenu(1)"
@keydown.exact.tab.prevent="navigateMenu(1)"
@keydown.shift.tab.prevent="navigateMenu(-1)"
>
<ul
id="context-menu"
ref="contextMenu"
role="menu"
:style="style"
tabindex="-1"
@mouseleave="activeItem = -1"
@keydown.enter.prevent="clickActiveItem"
>
<template v-for="(item, id) of items">
<template v-if="item.html">
<li
:key="item.name"
:class="[
'context-menu-' + item.type,
item.class ? 'context-menu-' + item.class : null,
{active: id === activeItem},
]"
role="menuitem"
@mouseenter="hoverItem(id)"
@click="clickItem(item)"
v-html="item.label"
></li>
</template>
<template v-if="!item.html">
<li
:key="item.name"
:class="[
'context-menu-' + item.type,
item.class ? 'context-menu-' + item.class : null,
{active: id === activeItem},
]"
role="menuitem"
@mouseenter="hoverItem(id)"
@click="clickItem(item)"
>
{{ item.label }}
</li>
</template>
</template>
</ul>
</div>
</template>
<script>
export default {
name: "ContextMenu",
props: {
message: Object,
},
data() {
return {
isOpen: false,
previousActiveElement: null,
items: [],
activeItem: -1,
style: {
left: 0,
top: 0,
},
};
},
mounted() {
this.$root.$on("escapekey", this.close);
},
destroyed() {
this.$root.$off("escapekey", this.close);
this.close();
},
methods: {
open(event, items) {
event.preventDefault();
this.previousActiveElement = document.activeElement;
this.items = items;
this.activeItem = 0;
this.isOpen = true;
// Position the menu and set the focus on the first item after it's size has updated
this.$nextTick(() => {
const pos = this.positionContextMenu(event);
this.style.left = pos.left + "px";
this.style.top = pos.top + "px";
this.$refs.contextMenu.focus();
});
},
close() {
if (!this.isOpen) {
return;
}
this.isOpen = false;
this.items = [];
if (this.previousActiveElement) {
this.previousActiveElement.focus();
this.previousActiveElement = null;
}
},
hoverItem(id) {
this.activeItem = id;
},
clickItem(item) {
this.close();
if (item.action) {
item.action();
} else if (item.link) {
this.$router.push(item.link);
}
},
clickActiveItem() {
if (this.items[this.activeItem]) {
this.clickItem(this.items[this.activeItem]);
}
},
navigateMenu(direction) {
let currentIndex = this.activeItem;
currentIndex += direction;
const nextItem = this.items[currentIndex];
// If the next item we would select is a divider, skip over it
if (nextItem && nextItem.type === "divider") {
currentIndex += direction;
}
if (currentIndex < 0) {
currentIndex += this.items.length;
}
if (currentIndex > this.items.length - 1) {
currentIndex -= this.items.length;
}
this.activeItem = currentIndex;
},
containerClick(event) {
if (event.currentTarget === event.target) {
this.close();
}
},
positionContextMenu(event) {
const element = event.target;
const menuWidth = this.$refs.contextMenu.offsetWidth;
const menuHeight = this.$refs.contextMenu.offsetHeight;
if (element && element.classList.contains("menu")) {
return {
left: element.getBoundingClientRect().left - (menuWidth - element.offsetWidth),
top: element.getBoundingClientRect().top + element.offsetHeight,
};
}
const offset = {left: event.pageX, top: event.pageY};
if (window.innerWidth - offset.left < menuWidth) {
offset.left = window.innerWidth - menuWidth;
}
if (window.innerHeight - offset.top < menuHeight) {
offset.top = window.innerHeight - menuHeight;
}
return offset;
},
},
};
</script>

View File

@ -210,24 +210,10 @@
</div>
<div class="description">
<p>
Mark any text typed after this shortcut to be colored. After hitting this
shortcut, enter an integer in the range
<code>015</code> to select the desired color, or use the autocompletion
menu to choose a color name (see below).
</p>
<p>
Background color can be specified by putting a comma and another integer in
the range <code>015</code> after the foreground color number
(autocompletion works too).
</p>
<p>
A color reference can be found
<a
href="https://modern.ircdocs.horse/formatting.html#colors"
target="_blank"
rel="noopener"
>here</a
>.
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
<kbd>Enter</kbd>. You can toggle between foreground and background color
with the buttons or by pressing <kbd>T</kbd>.
</p>
</div>
</div>
@ -239,7 +225,7 @@
</div>
<div class="description">
<p>
Mark all text typed after this shortcut as
Format selected text as
<span class="irc-bold">bold</span>.
</p>
</div>
@ -252,7 +238,7 @@
</div>
<div class="description">
<p>
Mark all text typed after this shortcut as
Format selected text as
<span class="irc-underline">underlined</span>.
</p>
</div>
@ -265,7 +251,7 @@
</div>
<div class="description">
<p>
Mark all text typed after this shortcut as
Format selected text as
<span class="irc-italic">italics</span>.
</p>
</div>
@ -278,7 +264,7 @@
</div>
<div class="description">
<p>
Mark all text typed after this shortcut as
Format selected text as
<span class="irc-strikethrough">struck through</span>.
</p>
</div>
@ -291,7 +277,7 @@
</div>
<div class="description">
<p>
Mark all text typed after this shortcut as
Format selected text as
<span class="irc-monospace">monospaced</span>.
</p>
</div>
@ -304,8 +290,7 @@
</div>
<div class="description">
<p>
Mark all text typed after this shortcut to be reset to its original
formatting.
Remove all formatting from selected text.
</p>
</div>
</div>

View File

@ -0,0 +1,435 @@
<template>
<div id="input" ref="container" class="wysiwyg-container">
<div ref="indicator" class="indicator"></div>
<IrcColorPicker ref="colorpicker" />
<div
id="wysiwyg-input"
ref="input"
dir="auto"
class="wysiwyg-input"
contenteditable="true"
:data-placeholder="placeholder"
:aria-label="placeholder"
@focus="onFocus"
@blur="onBlur"
></div>
</div>
</template>
<style>
.wysiwyg-container {
width: 100%;
max-width: 100%;
display: flex;
overflow: hidden;
}
.wysiwyg-container .wysiwyg-input {
width: 100%;
height: 100%;
cursor: text;
background: inherit;
outline: none;
overflow: hidden;
word-wrap: break-word;
}
/*.wysiwyg-container .wysiwyg-input:empty:not(:focus)::before {*/
.wysiwyg-container .wysiwyg-input:empty::before {
/*
Show a placeholder when the input is empty. Emptyness isn't guaranteed by
contenteditable but we empty with JS when it has no text content
*/
display: block;
content: attr(data-placeholder);
}
.wysiwyg-container .wysiwyg-input sub {
font-size: inherit;
font-family: monospace;
position: static !important;
}
/* TODO: remove this */
.wysiwyg-container .indicator {
display: inline-block;
display: none;
border: 1px solid black;
position: fixed;
z-index: 100;
}
</style>
<script>
import Mousetrap from "mousetrap";
import * as he from "he";
import IrcColorPicker from "./IrcColorPicker.vue";
import {
getLinesAsFragments,
cloneNodeTreeSelective,
cleanWysiwygMarkup,
} from "../js/helpers/wysiwyg";
// Mapping of HTML tag names to IRC format control characters
const tagToControlCharacter = {
b: "\x02",
br: "\n",
strong: "\x02",
em: "\x02",
u: "\x1F",
strike: "\x1e",
sub: "\x11",
i: "\x1D",
};
// Autocomplete bracket and quote characters like in a modern IDE
// For example, select `text`, press `[` key, and it becomes `[text]`
const bracketWraps = {
'"': '"',
"'": "'",
"(": ")",
"<": ">",
"[": "]",
"{": "}",
"*": "*",
"`": "`",
"~": "~",
_: "_",
};
// Key bindings for formatting
const formattingHotkeys = {
"mod+k": "color",
"mod+b": "bold",
"mod+u": "underline",
"mod+o": "removeFormat",
"mod+s": "strikeThrough",
"mod+i": "italic",
"mod+m": "monospace", // code tags not supported, we hack around it with subscript styled as monospace
};
// Convert a HTML string to IRC control character representation
function formatToControlCharacters(text) {
// This is regex based so possibly fragile but we have control over
// the formatting of the HTML and we sanitize it so it's workable
// For colors this relies on the fact that we never allow nested
const tagNames = Object.keys(tagToControlCharacter);
// Wrap basic HTML formatting tags to IRC control codes
for (const tagName of tagNames) {
text = text.replace(new RegExp("</?" + tagName + ">", "g"), tagToControlCharacter[tagName]);
}
// Convert our color spans to IRC colors
// First handle both colors
text = text.replace(
new RegExp('<span class="irc-fg(.*?) irc-bg(.*?)">(.*?)</span>', "g"),
"\x03$1,$2$3\x03"
);
// Then foreground and background separately
text = text.replace(new RegExp('<span class="irc-fg(.*?)">(.*?)</span>', "g"), "\x03$1$2\x03");
text = text.replace(
new RegExp('<span class="irc-bg(.*?)">(.*?)</span>', "g"),
"\x0300,$1$2\x03"
);
return text;
}
function toIrcFormat(text) {
return he.decode(formatToControlCharacters(text));
}
export default {
name: "WysiwygInput",
components: {
IrcColorPicker,
},
props: {
placeholder: String,
autoHeight: {
default: true,
type: Boolean,
},
},
data() {
return {
cleanPaste: true,
};
},
mounted() {
const inputTrap = Mousetrap(this.$refs.input);
// this.autocomplete = new Autocomplete(); // TODO
// Keep track of cursor pixel position
this.$refs.input.addEventListener("input", (e) => {
this.onInput(e);
});
// Submit
inputTrap.bind("enter", this.onSubmit);
// Newline
inputTrap.bind("shift+enter", this.newLine);
// Clean up pasted HTML
this.$refs.input.addEventListener("paste", (e) => {
if (this.cleanPaste) {
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
document.execCommand("insertText", false, text);
}
});
this.$refs.input.addEventListener("keyup", () => {
this.updateSelectionIndicator();
});
// Formatting
inputTrap.bind(Object.keys(formattingHotkeys), (e, key) => {
const command = formattingHotkeys[key];
this.runCommand(command);
return false;
});
// Bracket matching
inputTrap.bind(Object.keys(bracketWraps), (e, key) => {
if (this.surroundSelection(key, bracketWraps[key])) {
return false;
}
});
},
destroyed() {},
methods: {
isEmpty() {
return !this.$refs.input.textContent;
},
getHtmlContent() {
// TODO: format & setter
// TODO Move <br> removal to cleanup and do it dom based instead of string based
let html = this.$refs.input.innerHTML;
if (html.endsWith("<br>")) {
// Remove the last trailing newline
html = html.substring(0, html.length - 4);
}
return html;
},
setHtmlContent(html) {
this.$refs.input.innerHTML = html;
},
getLines() {
const sel = window.getSelection();
const range = sel.getRangeAt(0);
const fragments = getLinesAsFragments(this.$refs.input, range);
const lines = fragments.map((f) => {
const el = document.createElement("div");
el.appendChild(f);
return el.innerHTML;
});
return lines;
},
getIrcContent() {
return toIrcFormat(this.getHtmlContent());
},
getIrcLines() {
return this.getLines().map(toIrcFormat);
},
focus() {
this.$refs.input.focus();
},
blur() {
this.$refs.input.blur();
},
runCommand(command) {
if (command === "color") {
this.pickColor();
return;
}
if (command === "monospace") {
// Monospace doesn't exist so we hack around it with subscript
command = "subscript";
}
document.execCommand(command, false);
},
// Events
onFocus() {},
onBlur() {
if (this.isEmpty()) {
this.clear();
}
},
onSubmit(e) {
// FIXME: Implement html content normalization
cleanWysiwygMarkup(this.$refs.input);
this.$emit("submit", e);
const html = this.$refs.input.innerHTML;
const ircFormat = toIrcFormat(html);
document.getElementById("input").value = ircFormat;
},
onInput() {
this.onChange();
},
onChange() {
if (this.autoHeight) {
this.setAutoHeight();
}
this.updateSelectionIndicator();
},
// Actions
newLine() {
const sel = window.getSelection();
const range = sel.getRangeAt(0);
const element = document.createElement("br");
range.deleteContents();
range.insertNode(element);
range.setStartAfter(element);
this.onChange();
return false;
},
clear() {
this.$refs.input.innerHTML = "";
this.onChange();
},
surroundSelection(start, end) {
const sel = window.getSelection();
if (sel.type === "Range") {
const range = sel.getRangeAt(0);
const startNode = document.createTextNode(start);
const endNode = document.createTextNode(end);
range.insertNode(startNode);
range.collapse(false);
range.insertNode(endNode);
range.setStartAfter(startNode);
range.setEndBefore(endNode);
this.onChange();
return true;
}
return false;
},
setAutoHeight() {
this.$nextTick(() => {
// Start by resetting height before computing as scrollHeight does not
// decrease when deleting characters
if (!this.$refs.container) {
return;
}
this.$refs.container.style.height = "";
// Set the container height to the content height
this.$refs.container.style.height = this.$refs.input.scrollHeight + "px";
});
},
pickColor() {
// TODO: This should be broken up into parts
const sel = window.getSelection();
// If there is no selection do nothing (sel.type is `Caret`)
if (sel.type !== "Range") {
return;
}
// Get the cursor positon
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
const pos = {x: rect.left, y: rect.top - 5};
// Open the color picker above the current selection
this.$refs.colorpicker.open(pos, (colors) => {
this.focus();
// If the color picker was exited or no colors were chosen do nothing
if (!colors || (colors.fg === null && colors.bg === null)) {
return;
}
// Create the color wrapper element
const span = document.createElement("span");
// Set appropriate color classes
if (colors.fg !== null) {
span.classList.add("irc-fg" + colors.fg);
}
if (colors.bg !== null) {
span.classList.add("irc-bg" + colors.bg);
}
// Get the currently selected nodes and remove them from the dom
const currentSelection = range.extractContents();
// Clone the selected tree, remove any spans but keep their content
const newTree = cloneNodeTreeSelective(
currentSelection,
(el) => el.nodeName === "SPAN"
);
// Insert the nodes into the span
for (const element of newTree.childNodes) {
span.appendChild(element.cloneNode(true));
}
// Insert the color span into the container
range.insertNode(span);
// Split the dom at the boundaries of the selection to break any possible parent spans
// This is done by removing and re-inserting the nodes before and after the selection
// Extract and re-insert everything before the users selection
range.setStart(this.$refs.input, 0);
range.setEndBefore(span);
range.insertNode(range.extractContents());
// Extract and re-insert everything after the users selection
range.selectNodeContents(this.$refs.input);
range.setStartAfter(span);
range.insertNode(range.extractContents());
if (span.parentNode.nodeName === "SPAN") {
// If still nested in a color tag, replace the parent with the current color
span.parentNode.replaceWith(span);
}
// Recreate the original selection
range.setStartAfter(span);
range.setEndBefore(span);
});
},
updateSelectionIndicator() {
// TODO: this is only for debugging
const sel = window.getSelection();
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
const indicator = this.$refs.indicator;
indicator.style.top = rect.top + "px";
indicator.style.left = rect.left + "px";
indicator.style.width = rect.width + "px";
indicator.style.height = rect.height + "px";
return false;
},
},
};
</script>

View File

@ -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 */
@ -287,14 +288,14 @@ p {
#chat button.close::before,
#chat button.menu::before,
#chat button.search::before,
.toolbar button.format::before,
.channel-list-item::before,
#footer .icon,
#chat .count::before,
#connect .extra-help,
#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,
@ -333,6 +334,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 */
@ -2189,7 +2191,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;
}
@ -2198,8 +2199,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;
@ -2207,8 +2207,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;
}
@ -2224,6 +2223,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
}
.mentions-popup,
.floating-container,
#context-menu,
.textcomplete-menu {
position: absolute;

View File

@ -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 `<span class="emoji">${emojiMap[original]}</span> ${string}`;

View File

@ -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";
};

View File

@ -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;
}

View File

@ -185,7 +185,7 @@ document.addEventListener("keydown", (e) => {
return;
}
const input = document.getElementById("input");
const input = document.getElementById("wysiwyg-input");
if (!input) {
return;

View File

@ -50,6 +50,7 @@
"file-type": "16.2.0",
"filenamify": "4.2.0",
"got": "11.8.1",
"he": "1.2.0",
"irc-framework": "4.9.0",
"is-utf8": "0.2.1",
"ldapjs": "2.2.3",