mirror of
https://github.com/thelounge/thelounge.git
synced 2024-05-27 02:42:17 +02:00
Wysiwyg WIP.
This commit is contained in:
parent
beac893dd0
commit
76d736199b
|
@ -1,18 +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"
|
||||
: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"
|
||||
|
@ -24,12 +77,14 @@
|
|||
id="upload-input"
|
||||
ref="uploadInput"
|
||||
type="file"
|
||||
class="chat-input-button"
|
||||
multiple
|
||||
@change="onUploadInputChange"
|
||||
/>
|
||||
<button
|
||||
id="upload"
|
||||
type="button"
|
||||
class="chat-input-button"
|
||||
aria-label="Upload file"
|
||||
:disabled="!$store.state.isConnected"
|
||||
/>
|
||||
|
@ -42,6 +97,7 @@
|
|||
<button
|
||||
id="submit"
|
||||
type="submit"
|
||||
class="chat-input-button"
|
||||
aria-label="Send message"
|
||||
:disabled="!$store.state.isConnected"
|
||||
/>
|
||||
|
@ -49,86 +105,113 @@
|
|||
</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";
|
||||
|
||||
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() {
|
||||
this.$root.$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 ||
|
||||
|
@ -157,6 +240,7 @@ export default {
|
|||
|
||||
return false;
|
||||
});
|
||||
*/
|
||||
|
||||
if (this.$store.state.serverConfiguration.fileUpload) {
|
||||
upload.mounted();
|
||||
|
@ -178,22 +262,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}`;
|
||||
|
@ -201,6 +269,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)
|
||||
|
@ -251,6 +338,7 @@ export default {
|
|||
|
||||
socket.emit("input", {target, text});
|
||||
},
|
||||
*/
|
||||
onUploadInputChange() {
|
||||
const files = Array.from(this.$refs.uploadInput.files);
|
||||
upload.triggerUpload(files);
|
||||
|
@ -260,7 +348,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;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -1,78 +1,40 @@
|
|||
<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";
|
||||
|
||||
export default {
|
||||
name: "ContextMenu",
|
||||
components: {
|
||||
SelectPopup,
|
||||
},
|
||||
props: {
|
||||
message: Object,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isOpen: false,
|
||||
previousActiveElement: null,
|
||||
items: [],
|
||||
activeItem: -1,
|
||||
style: {
|
||||
left: 0,
|
||||
top: 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.$root.$on("escapekey", this.close);
|
||||
this.$root.$on("contextmenu:user", this.openUserContextMenu);
|
||||
this.$root.$on("contextmenu:channel", this.openChannelContextMenu);
|
||||
this.$root.$on("contextmenu:custom", this.openCustomContextMenu);
|
||||
},
|
||||
destroyed() {
|
||||
this.$root.$off("escapekey", this.close);
|
||||
this.$root.$off("contextmenu:user", this.openUserContextMenu);
|
||||
this.$root.$off("contextmenu:channel", this.openChannelContextMenu);
|
||||
this.$root.$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;
|
||||
|
@ -83,104 +45,8 @@ export default {
|
|||
network,
|
||||
channel.users.find((u) => u.nick === data.user.nick) || {nick: data.user.nick}
|
||||
);
|
||||
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);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
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 class="description">
|
||||
<p>
|
||||
Mark any text typed after this shortcut to be colored. After hitting this
|
||||
shortcut, enter an integer in the range
|
||||
<code>0—15</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>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
|
||||
>.
|
||||
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>
|
||||
|
|
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);
|
||||
}
|
||||
|
||||
.wysiwyg-container .wysiwyg-input:empty::before,
|
||||
::placeholder {
|
||||
color: rgba(0, 0, 0, 0.35);
|
||||
opacity: 1; /* fix opacity in Firefox */
|
||||
|
@ -284,13 +285,13 @@ p {
|
|||
#viewport .lt::before,
|
||||
#viewport .rt::before,
|
||||
#chat button.menu::before,
|
||||
.toolbar button.format::before,
|
||||
.channel-list-item::before,
|
||||
#footer .icon,
|
||||
#chat .count::before,
|
||||
#settings .extra-help,
|
||||
#settings #play::before,
|
||||
#form #upload::before,
|
||||
#form #submit::before,
|
||||
#form .chat-input-button::before,
|
||||
#chat .msg[data-type="away"] .from::before,
|
||||
#chat .msg[data-type="back"] .from::before,
|
||||
#chat .msg[data-type="invite"] .from::before,
|
||||
|
@ -326,6 +327,7 @@ p {
|
|||
.channel-list-item .not-connected-icon::before,
|
||||
.channel-list-item .parted-channel-icon::before,
|
||||
.jump-to-input::before,
|
||||
.colorpicker .tools button::before,
|
||||
#sidebar .collapse-network-icon::before {
|
||||
font: normal normal normal 14px/1 FontAwesome;
|
||||
font-size: inherit; /* Can't have font-size inherit on line above, so need to override */
|
||||
|
@ -2143,7 +2145,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
|||
margin: 5px;
|
||||
padding: 0;
|
||||
resize: none;
|
||||
flex: 1 0 auto;
|
||||
align-self: center;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
@ -2152,8 +2153,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
|||
display: none;
|
||||
}
|
||||
|
||||
#form #upload,
|
||||
#form #submit {
|
||||
#form .chat-input-button {
|
||||
color: #607992;
|
||||
font-size: 14px;
|
||||
height: 32px;
|
||||
|
@ -2161,8 +2161,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
|||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
#form #upload[disabled],
|
||||
#form #submit[disabled] {
|
||||
#form .chat-input-button[disabled] {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
|
@ -2176,6 +2175,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */
|
|||
background: transparent;
|
||||
}
|
||||
|
||||
.floating-container,
|
||||
#context-menu,
|
||||
.textcomplete-menu {
|
||||
position: absolute;
|
||||
|
|
|
@ -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}`;
|
||||
|
|
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;
|
||||
}
|
||||
|
||||
const input = document.getElementById("input");
|
||||
const input = document.getElementById("wysiwyg-input");
|
||||
|
||||
if (!input) {
|
||||
return;
|
||||
|
|
|
@ -50,6 +50,7 @@
|
|||
"file-type": "14.1.4",
|
||||
"filenamify": "4.1.0",
|
||||
"got": "10.6.0",
|
||||
"he": "1.2.0",
|
||||
"irc-framework": "4.7.0",
|
||||
"is-utf8": "0.2.1",
|
||||
"ldapjs": "2.0.0-pre.5",
|
||||
|
|
Loading…
Reference in a new issue