mirror of
https://github.com/thelounge/thelounge.git
synced 2024-05-28 03:12:27 +02:00
Wysiwyg WIP.
This commit is contained in:
parent
db807d0c56
commit
ffb6d811fa
|
@ -1,19 +1,71 @@
|
||||||
<template>
|
<template>
|
||||||
<form id="form" method="post" action="" @submit.prevent="onSubmit">
|
<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="upload-progressbar" />
|
||||||
<span id="nick">{{ network.nick }}</span>
|
<span id="nick">{{ network.nick }}</span>
|
||||||
<textarea
|
<WysiwygInput
|
||||||
id="input"
|
ref="wysiwyg"
|
||||||
ref="input"
|
|
||||||
dir="auto"
|
|
||||||
class="mousetrap"
|
|
||||||
enterkeyhint="send"
|
|
||||||
:value="channel.pendingMessage"
|
|
||||||
:placeholder="getInputPlaceholder(channel)"
|
:placeholder="getInputPlaceholder(channel)"
|
||||||
:aria-label="getInputPlaceholder(channel)"
|
@submit="onSubmit"
|
||||||
@input="setPendingMessage"
|
|
||||||
@keypress.enter.exact.prevent="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
|
<span
|
||||||
v-if="$store.state.serverConfiguration.fileUpload"
|
v-if="$store.state.serverConfiguration.fileUpload"
|
||||||
id="upload-tooltip"
|
id="upload-tooltip"
|
||||||
|
@ -26,12 +78,14 @@
|
||||||
ref="uploadInput"
|
ref="uploadInput"
|
||||||
type="file"
|
type="file"
|
||||||
aria-labelledby="upload"
|
aria-labelledby="upload"
|
||||||
|
class="chat-input-button"
|
||||||
multiple
|
multiple
|
||||||
@change="onUploadInputChange"
|
@change="onUploadInputChange"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
id="upload"
|
id="upload"
|
||||||
type="button"
|
type="button"
|
||||||
|
class="chat-input-button"
|
||||||
aria-label="Upload file"
|
aria-label="Upload file"
|
||||||
:disabled="!$store.state.isConnected"
|
:disabled="!$store.state.isConnected"
|
||||||
/>
|
/>
|
||||||
|
@ -44,6 +98,7 @@
|
||||||
<button
|
<button
|
||||||
id="submit"
|
id="submit"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
class="chat-input-button"
|
||||||
aria-label="Send message"
|
aria-label="Send message"
|
||||||
:disabled="!$store.state.isConnected"
|
:disabled="!$store.state.isConnected"
|
||||||
/>
|
/>
|
||||||
|
@ -51,87 +106,114 @@
|
||||||
</form>
|
</form>
|
||||||
</template>
|
</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>
|
<script>
|
||||||
import Mousetrap from "mousetrap";
|
// import autocompletion from "../js/autocompletion"; TODO
|
||||||
import {wrapCursor} from "undate";
|
// import commands from "../js/commands/index"; TODO
|
||||||
import autocompletion from "../js/autocompletion";
|
|
||||||
import commands from "../js/commands/index";
|
|
||||||
import socket from "../js/socket";
|
import socket from "../js/socket";
|
||||||
import upload from "../js/upload";
|
import upload from "../js/upload";
|
||||||
import eventbus from "../js/eventbus";
|
import eventbus from "../js/eventbus";
|
||||||
|
import WysiwygInput from "./WysiwygInput.vue";
|
||||||
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 = {
|
|
||||||
'"': '"',
|
|
||||||
"'": "'",
|
|
||||||
"(": ")",
|
|
||||||
"<": ">",
|
|
||||||
"[": "]",
|
|
||||||
"{": "}",
|
|
||||||
"*": "*",
|
|
||||||
"`": "`",
|
|
||||||
"~": "~",
|
|
||||||
_: "_",
|
|
||||||
};
|
|
||||||
|
|
||||||
let autocompletionRef = null;
|
let autocompletionRef = null;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ChatInput",
|
name: "ChatInput",
|
||||||
|
components: {
|
||||||
|
WysiwygInput,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
network: Object,
|
network: Object,
|
||||||
channel: Object,
|
channel: Object,
|
||||||
},
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showToolbar: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
watch: {
|
watch: {
|
||||||
"channel.id"() {
|
"channel.id"() {
|
||||||
if (autocompletionRef) {
|
if (autocompletionRef) {
|
||||||
autocompletionRef.hide();
|
autocompletionRef.hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.closeToolbar();
|
||||||
},
|
},
|
||||||
"channel.pendingMessage"() {
|
"channel.pendingMessage"() {
|
||||||
this.setInputSize();
|
this.closeToolbar();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
eventbus.on("escapekey", this.blurInput);
|
eventbus.on("escapekey", this.blurInput);
|
||||||
|
|
||||||
|
/* TODO
|
||||||
if (this.$store.state.settings.autocomplete) {
|
if (this.$store.state.settings.autocomplete) {
|
||||||
autocompletionRef = autocompletion(this.$refs.input);
|
autocompletionRef = autocompletion(this.$refs.input);
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* TODO
|
||||||
const inputTrap = Mousetrap(this.$refs.input);
|
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) => {
|
inputTrap.bind(["up", "down"], (e, key) => {
|
||||||
if (
|
if (
|
||||||
this.$store.state.isAutoCompleting ||
|
this.$store.state.isAutoCompleting ||
|
||||||
|
@ -160,6 +242,7 @@ export default {
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
*/
|
||||||
|
|
||||||
if (this.$store.state.serverConfiguration.fileUpload) {
|
if (this.$store.state.serverConfiguration.fileUpload) {
|
||||||
upload.mounted();
|
upload.mounted();
|
||||||
|
@ -181,22 +264,6 @@ export default {
|
||||||
this.channel.inputHistoryPosition = 0;
|
this.channel.inputHistoryPosition = 0;
|
||||||
this.setInputSize();
|
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) {
|
getInputPlaceholder(channel) {
|
||||||
if (channel.type === "channel" || channel.type === "query") {
|
if (channel.type === "channel" || channel.type === "query") {
|
||||||
return `Write to ${channel.name}`;
|
return `Write to ${channel.name}`;
|
||||||
|
@ -204,6 +271,25 @@ export default {
|
||||||
|
|
||||||
return "";
|
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() {
|
onSubmit() {
|
||||||
// Triggering click event opens the virtual keyboard on mobile
|
// Triggering click event opens the virtual keyboard on mobile
|
||||||
// This can only be called from another interactive event (e.g. button click)
|
// This can only be called from another interactive event (e.g. button click)
|
||||||
|
@ -254,6 +340,7 @@ export default {
|
||||||
|
|
||||||
socket.emit("input", {target, text});
|
socket.emit("input", {target, text});
|
||||||
},
|
},
|
||||||
|
*/
|
||||||
onUploadInputChange() {
|
onUploadInputChange() {
|
||||||
const files = Array.from(this.$refs.uploadInput.files);
|
const files = Array.from(this.$refs.uploadInput.files);
|
||||||
upload.triggerUpload(files);
|
upload.triggerUpload(files);
|
||||||
|
@ -263,7 +350,20 @@ export default {
|
||||||
this.$refs.uploadInput.click();
|
this.$refs.uploadInput.click();
|
||||||
},
|
},
|
||||||
blurInput() {
|
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;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,79 +1,43 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<SelectPopup ref="select" />
|
||||||
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>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import SelectPopup from "./SelectPopup.vue";
|
||||||
import {generateUserContextMenu, generateChannelContextMenu} from "../js/helpers/contextMenu.js";
|
import {generateUserContextMenu, generateChannelContextMenu} from "../js/helpers/contextMenu.js";
|
||||||
import eventbus from "../js/eventbus";
|
import eventbus from "../js/eventbus";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "ContextMenu",
|
name: "ContextMenu",
|
||||||
|
components: {
|
||||||
|
SelectPopup,
|
||||||
|
},
|
||||||
props: {
|
props: {
|
||||||
message: Object,
|
message: Object,
|
||||||
},
|
},
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
isOpen: false,
|
|
||||||
previousActiveElement: null,
|
|
||||||
items: [],
|
|
||||||
activeItem: -1,
|
|
||||||
style: {
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
mounted() {
|
mounted() {
|
||||||
eventbus.on("escapekey", this.close);
|
eventbus.on("escapekey", this.close);
|
||||||
eventbus.on("contextmenu:user", this.openUserContextMenu);
|
eventbus.on("contextmenu:user", this.openUserContextMenu);
|
||||||
eventbus.on("contextmenu:channel", this.openChannelContextMenu);
|
eventbus.on("contextmenu:channel", this.openChannelContextMenu);
|
||||||
|
eventbus.on("contextmenu:custom", this.openCustomContextMenu);
|
||||||
},
|
},
|
||||||
destroyed() {
|
destroyed() {
|
||||||
eventbus.off("escapekey", this.close);
|
eventbus.off("escapekey", this.close);
|
||||||
eventbus.off("contextmenu:user", this.openUserContextMenu);
|
eventbus.off("contextmenu:user", this.openUserContextMenu);
|
||||||
eventbus.off("contextmenu:channel", this.openChannelContextMenu);
|
eventbus.off("contextmenu:channel", this.openChannelContextMenu);
|
||||||
|
eventbus.off("contextmenu:custom", this.openCustomContextMenu);
|
||||||
|
|
||||||
this.close();
|
if (this.$refs.select) {
|
||||||
|
this.$refs.select.close();
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
openCustomContextMenu(data) {
|
||||||
|
this.$refs.select.open(data.event, data.items);
|
||||||
|
},
|
||||||
openChannelContextMenu(data) {
|
openChannelContextMenu(data) {
|
||||||
const items = generateChannelContextMenu(this.$root, data.channel, data.network);
|
const items = generateChannelContextMenu(this.$root, data.channel, data.network);
|
||||||
this.open(data.event, items);
|
this.$refs.select.open(data.event, items);
|
||||||
},
|
},
|
||||||
openUserContextMenu(data) {
|
openUserContextMenu(data) {
|
||||||
const {network, channel} = this.$store.state.activeChannel;
|
const {network, channel} = this.$store.state.activeChannel;
|
||||||
|
@ -87,104 +51,8 @@ export default {
|
||||||
modes: [],
|
modes: [],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
this.open(data.event, items);
|
|
||||||
},
|
|
||||||
open(event, items) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
this.previousActiveElement = document.activeElement;
|
this.$refs.select.open(data.event, items);
|
||||||
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;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
296
client/components/IrcColorPicker.vue
Normal file
296
client/components/IrcColorPicker.vue
Normal 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>
|
182
client/components/SelectPopup.vue
Normal file
182
client/components/SelectPopup.vue
Normal 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>
|
|
@ -210,24 +210,10 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>
|
<p>
|
||||||
Mark any text typed after this shortcut to be colored. After hitting this
|
Open a color picker for coloring the currently selected text. You can select
|
||||||
shortcut, enter an integer in the range
|
a color with the mouse or arrow keys and apply the color by pressing
|
||||||
<code>0—15</code> to select the desired color, or use the autocompletion
|
<kbd>Enter</kbd>. You can toggle between foreground and background color
|
||||||
menu to choose a color name (see below).
|
with the buttons or by pressing <kbd>T</kbd>.
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Background color can be specified by putting a comma and another integer in
|
|
||||||
the range <code>0—15</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
|
|
||||||
>.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -239,7 +225,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>
|
<p>
|
||||||
Mark all text typed after this shortcut as
|
Format selected text as
|
||||||
<span class="irc-bold">bold</span>.
|
<span class="irc-bold">bold</span>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -252,7 +238,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>
|
<p>
|
||||||
Mark all text typed after this shortcut as
|
Format selected text as
|
||||||
<span class="irc-underline">underlined</span>.
|
<span class="irc-underline">underlined</span>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -265,7 +251,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>
|
<p>
|
||||||
Mark all text typed after this shortcut as
|
Format selected text as
|
||||||
<span class="irc-italic">italics</span>.
|
<span class="irc-italic">italics</span>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -278,7 +264,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>
|
<p>
|
||||||
Mark all text typed after this shortcut as
|
Format selected text as
|
||||||
<span class="irc-strikethrough">struck through</span>.
|
<span class="irc-strikethrough">struck through</span>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -291,7 +277,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>
|
<p>
|
||||||
Mark all text typed after this shortcut as
|
Format selected text as
|
||||||
<span class="irc-monospace">monospaced</span>.
|
<span class="irc-monospace">monospaced</span>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -304,8 +290,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="description">
|
<div class="description">
|
||||||
<p>
|
<p>
|
||||||
Mark all text typed after this shortcut to be reset to its original
|
Remove all formatting from selected text.
|
||||||
formatting.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
435
client/components/WysiwygInput.vue
Normal file
435
client/components/WysiwygInput.vue
Normal 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>
|
|
@ -42,6 +42,7 @@
|
||||||
--upload-progressbar-color: var(--button-color);
|
--upload-progressbar-color: var(--button-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.wysiwyg-container .wysiwyg-input:empty::before,
|
||||||
::placeholder {
|
::placeholder {
|
||||||
color: rgba(0, 0, 0, 0.35);
|
color: rgba(0, 0, 0, 0.35);
|
||||||
opacity: 1; /* fix opacity in Firefox */
|
opacity: 1; /* fix opacity in Firefox */
|
||||||
|
@ -287,14 +288,14 @@ p {
|
||||||
#chat button.close::before,
|
#chat button.close::before,
|
||||||
#chat button.menu::before,
|
#chat button.menu::before,
|
||||||
#chat button.search::before,
|
#chat button.search::before,
|
||||||
|
.toolbar button.format::before,
|
||||||
.channel-list-item::before,
|
.channel-list-item::before,
|
||||||
#footer .icon,
|
#footer .icon,
|
||||||
#chat .count::before,
|
#chat .count::before,
|
||||||
#connect .extra-help,
|
#connect .extra-help,
|
||||||
#settings .extra-help,
|
#settings .extra-help,
|
||||||
#settings #play::before,
|
#settings #play::before,
|
||||||
#form #upload::before,
|
#form .chat-input-button::before,
|
||||||
#form #submit::before,
|
|
||||||
#chat .msg[data-type="away"] .from::before,
|
#chat .msg[data-type="away"] .from::before,
|
||||||
#chat .msg[data-type="back"] .from::before,
|
#chat .msg[data-type="back"] .from::before,
|
||||||
#chat .msg[data-type="invite"] .from::before,
|
#chat .msg[data-type="invite"] .from::before,
|
||||||
|
@ -333,6 +334,7 @@ p {
|
||||||
.channel-list-item .not-connected-icon::before,
|
.channel-list-item .not-connected-icon::before,
|
||||||
.channel-list-item .parted-channel-icon::before,
|
.channel-list-item .parted-channel-icon::before,
|
||||||
.jump-to-input::before,
|
.jump-to-input::before,
|
||||||
|
.colorpicker .tools button::before,
|
||||||
#sidebar .collapse-network-icon::before {
|
#sidebar .collapse-network-icon::before {
|
||||||
font: normal normal normal 14px/1 FontAwesome;
|
font: normal normal normal 14px/1 FontAwesome;
|
||||||
font-size: inherit; /* Can't have font-size inherit on line above, so need to override */
|
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;
|
margin: 5px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
resize: none;
|
resize: none;
|
||||||
flex: 1 0 auto;
|
|
||||||
align-self: center;
|
align-self: center;
|
||||||
touch-action: pan-y;
|
touch-action: pan-y;
|
||||||
}
|
}
|
||||||
|
@ -2198,8 +2199,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#form #upload,
|
#form .chat-input-button {
|
||||||
#form #submit {
|
|
||||||
color: #607992;
|
color: #607992;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
|
@ -2207,8 +2207,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#form #upload[disabled],
|
#form .chat-input-button[disabled] {
|
||||||
#form #submit[disabled] {
|
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -2224,6 +2223,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
||||||
}
|
}
|
||||||
|
|
||||||
.mentions-popup,
|
.mentions-popup,
|
||||||
|
.floating-container,
|
||||||
#context-menu,
|
#context-menu,
|
||||||
.textcomplete-menu {
|
.textcomplete-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
@ -19,7 +19,8 @@ const emojiStrategy = {
|
||||||
// Trim colon from the matched term,
|
// Trim colon from the matched term,
|
||||||
// as we are unable to get a clean string from match regex
|
// as we are unable to get a clean string from match regex
|
||||||
term = term.replace(/:$/, "");
|
term = term.replace(/:$/, "");
|
||||||
callback(fuzzyGrep(term, emojiSearchTerms));
|
const res = fuzzyGrep(term, emojiSearchTerms);
|
||||||
|
callback(res);
|
||||||
},
|
},
|
||||||
template([string, original]) {
|
template([string, original]) {
|
||||||
return `<span class="emoji">${emojiMap[original]}</span> ${string}`;
|
return `<span class="emoji">${emojiMap[original]}</span> ${string}`;
|
||||||
|
|
33
client/js/helpers/positionElement.js
Normal file
33
client/js/helpers/positionElement.js
Normal 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";
|
||||||
|
};
|
113
client/js/helpers/wysiwyg.js
Normal file
113
client/js/helpers/wysiwyg.js
Normal 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;
|
||||||
|
}
|
|
@ -185,7 +185,7 @@ document.addEventListener("keydown", (e) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const input = document.getElementById("input");
|
const input = document.getElementById("wysiwyg-input");
|
||||||
|
|
||||||
if (!input) {
|
if (!input) {
|
||||||
return;
|
return;
|
||||||
|
|
|
@ -50,6 +50,7 @@
|
||||||
"file-type": "16.2.0",
|
"file-type": "16.2.0",
|
||||||
"filenamify": "4.2.0",
|
"filenamify": "4.2.0",
|
||||||
"got": "11.8.1",
|
"got": "11.8.1",
|
||||||
|
"he": "1.2.0",
|
||||||
"irc-framework": "4.9.0",
|
"irc-framework": "4.9.0",
|
||||||
"is-utf8": "0.2.1",
|
"is-utf8": "0.2.1",
|
||||||
"ldapjs": "2.2.3",
|
"ldapjs": "2.2.3",
|
||||||
|
|
Loading…
Reference in a new issue