Files
raven-client/ayanova/src/components/wiki-control.vue
2020-04-19 21:55:14 +00:00

834 lines
27 KiB
Vue

<template>
<div>
<template v-if="readOnly">
<div>
<v-btn depressed tile @click="switchView()">
Wiki<v-icon right>{{ switchViewIcon() }}</v-icon></v-btn
>
</div>
</template>
<template v-else>
<span class="v-label v-label--active theme--light">
Wiki
</span>
<div class="mt-2">
<v-btn-toggle v-model="currentView">
<v-btn color="white" :value="view.HIDDEN_VIEW">
<v-icon>fa-eye-slash</v-icon>
</v-btn>
<v-btn color="white" :value="view.WIKI_VIEW">
<v-icon>fa-eye</v-icon>
</v-btn>
<v-btn color="white" :value="view.DESIGN_VIEW">
<v-icon>fa-edit</v-icon>
</v-btn>
<v-btn color="white" :value="view.SPLIT_VIEW">
<v-icon>fa-columns</v-icon>
</v-btn>
</v-btn-toggle>
</div>
</template>
<v-sheet
v-if="currentView != this.view.HIDDEN_VIEW"
elevation="2"
class="aywiki pa-2 pa-sm-6 mt-2"
>
<v-row v-resize="onResize">
<!-- BUTTONS -->
<v-col v-if="showDesigner()" :cols="12">
<div>
<v-btn depressed tile @click="editClick('bold')">
<v-icon>fa-bold</v-icon></v-btn
>
<v-btn depressed tile @click="editClick('italic')">
<v-icon>fa-italic</v-icon></v-btn
>
<v-btn depressed tile @click="editClick('strike')">
<v-icon>fa-strikethrough</v-icon></v-btn
>
<!-- HEADINGS -->
<v-menu offset-y>
<template v-slot:activator="{ on }">
<v-btn depressed tile v-on="on">
<v-icon>fa-heading</v-icon>
</v-btn>
</template>
<v-list>
<v-list-item @click="editClick('heading', 1)">
<v-list-item-title
><h1>{{ $ay.t("Heading") }} 1</h1></v-list-item-title
>
</v-list-item>
<v-list-item @click="editClick('heading', 2)">
<v-list-item-title
><h2>{{ $ay.t("Heading") }} 2</h2></v-list-item-title
>
</v-list-item>
<v-list-item @click="editClick('heading', 3)">
<v-list-item-title
><h3>{{ $ay.t("Heading") }} 3</h3></v-list-item-title
>
</v-list-item>
<v-list-item @click="editClick('heading', 4)">
<v-list-item-title
><h4>{{ $ay.t("Heading") }} 4</h4></v-list-item-title
>
</v-list-item>
<v-list-item @click="editClick('heading', 5)">
<v-list-item-title
><h5>{{ $ay.t("Heading") }} 5</h5></v-list-item-title
>
</v-list-item>
<v-list-item @click="editClick('heading', 6)">
<v-list-item-title
><h6>{{ $ay.t("Heading") }} 6</h6></v-list-item-title
>
</v-list-item>
</v-list>
</v-menu>
<!-- /HEADINGS -->
<!-- MORE BUTTONS -->
<v-btn depressed tile class="ml-1" @click="editClick('line')">
<v-icon>fa-minus</v-icon></v-btn
>
<v-btn depressed tile @click="editClick('quote')">
<v-icon>fa-quote-left</v-icon></v-btn
>
<v-btn depressed tile @click="editClick('ul')">
<v-icon>fa-list-ul</v-icon></v-btn
>
<v-btn depressed tile @click="editClick('ol')">
<v-icon>fa-list-ol</v-icon></v-btn
>
<v-btn depressed tile @click="editClick('task')">
<v-icon>fa-check-square</v-icon></v-btn
>
<!-- TABLES -->
<v-menu
v-model="tableMenu"
:close-on-content-click="false"
offset-y
>
<template v-slot:activator="{ on }">
<v-btn depressed tile class="ml-1" v-on="on">
<v-icon>fa-table</v-icon>
</v-btn>
</template>
<v-card width="300">
<v-card-title>{{ $ay.t("Table") }}</v-card-title>
<div class="ma-8">
<v-slider
thumb-size="24"
thumb-label="always"
v-model="tableMenuColumns"
min="1"
max="10"
prepend-icon="fa-arrows-alt-h"
></v-slider>
<v-slider
prepend-icon="fa-arrows-alt-v"
class="mt-8"
thumb-size="24"
thumb-label="always"
v-model="tableMenuRows"
min="1"
max="15"
></v-slider>
</div>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="tableMenu = false">{{
$ay.t("Cancel")
}}</v-btn>
<v-btn color="primary" text @click="editClick('table')">{{
$ay.t("OK")
}}</v-btn>
</v-card-actions>
</v-card>
</v-menu>
<!-- /TABLES -->
<!-- LINK -->
<v-btn depressed tile @click="openLinkMenu">
<v-icon>fa-link</v-icon>
</v-btn>
<v-menu
min-width="300"
v-model="linkMenu"
:close-on-content-click="false"
offset-y
:position-x="menuX"
:position-y="menuY"
absolute
>
<v-card width="300">
<v-card-title>{{ $ay.t("InsertLink") }}</v-card-title>
<div class="ma-8">
<v-text-field
v-model="linkUrl"
:label="$ay.t('LinkUrl')"
></v-text-field>
<v-text-field
v-model="linkText"
:label="$ay.t('LinkText')"
></v-text-field>
</div>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="linkMenu = false">{{
$ay.t("Cancel")
}}</v-btn>
<v-btn color="primary" text @click="editClick('link')">{{
$ay.t("OK")
}}</v-btn>
</v-card-actions>
</v-card>
</v-menu>
<!-- /LINKS -->
<!-- IMAGE -->
<v-btn depressed tile @click="openImageMenu">
<v-icon>fa-image</v-icon>
</v-btn>
<v-menu
min-width="360"
v-model="imageMenu"
:close-on-content-click="false"
offset-y
:position-x="menuX"
:position-y="menuY"
absolute
>
<v-card>
<v-card-title>{{ $ay.t("InsertImage") }}</v-card-title>
<div>
<v-tabs v-model="imageTab" grow color="primary">
<v-tab key="url">URL</v-tab>
<v-tab key="file">{{ $ay.t("Attachments") }}</v-tab>
<v-tabs-items v-model="imageTab">
<v-tab-item key="url"
><div class="ma-6">
<v-text-field
v-model="imageUrl"
:label="$ay.t('ImageUrl')"
></v-text-field>
<v-text-field
v-model="imageText"
:label="$ay.t('ImageDescription')"
></v-text-field></div
></v-tab-item>
<v-tab-item key="file"
><div class="ma-6">
<v-select
:label="$ay.t('Attachments')"
v-model="selectedImageAttachment"
:items="availableAttachments()"
item-text="name"
item-value="id"
></v-select>
<!--
<v-file-input
:label="$ay.t('AttachFile')"
prepend-icon="fa-paperclip"
></v-file-input> -->
<v-text-field
v-model="imageText"
:label="$ay.t('ImageDescription')"
></v-text-field></div
></v-tab-item>
</v-tabs-items>
</v-tabs>
</div>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn text @click="imageMenu = false">{{
$ay.t("Cancel")
}}</v-btn>
<v-btn
color="primary"
text
@click="editClick('image', imageTab)"
>{{ $ay.t("OK") }}</v-btn
>
</v-card-actions>
</v-card>
</v-menu>
<!-- /IMAGE -->
<!-- CODE -->
<v-btn depressed tile class="ml-1" @click="editClick('code')">
<v-icon>fa-code</v-icon></v-btn
>
<v-btn depressed tile @click="editClick('codeblock')">
<v-icon>fa-square-full</v-icon></v-btn
>
<!-- END OF BUTTONS -->
</div>
</v-col>
<!-- DESIGNER -->
<v-col
v-if="showDesigner()"
:cols="currentView == view.SPLIT_VIEW ? 6 : 12"
>
<div :style="editStyle()">
<v-textarea
solo
no-resize
:height="editAreaHeight"
ref="editArea"
@input="handleInput"
@dblclick="handleDoubleClick"
v-model="localVal"
></v-textarea>
</div>
</v-col>
<!-- WIKI auto-grow-->
<v-col
v-if="showWiki()"
:cols="currentView == view.SPLIT_VIEW ? 6 : 12"
>
<div
:style="wikiStyle()"
class="aywiki"
v-html="compiledOutput()"
></div>
</v-col>
</v-row>
</v-sheet>
</div>
</template>
<script>
/*
This code written in Mid April 2020 about 4 weeks into Covid-19 self isolation
It's just starting to look like BC has "flattened the curve", but other places are still a shit-show
*/
import marked from "marked";
import DOMPurify from "dompurify";
export default {
created() {
// Add a hook to make all links open a new window
//https://github.com/cure53/DOMPurify/blob/master/demos/hooks-target-blank-demo.html
DOMPurify.addHook("afterSanitizeAttributes", function(node) {
// set all elements owning target to target=_blank
if ("target" in node) {
node.setAttribute("target", "_blank");
// prevent https://www.owasp.org/index.php/Reverse_Tabnabbing
node.setAttribute("rel", "noopener noreferrer");
}
// set non-HTML/MathML links to xlink:show=new
if (
!node.hasAttribute("target") &&
(node.hasAttribute("xlink:href") || node.hasAttribute("href"))
) {
node.setAttribute("xlink:show", "new");
}
});
},
data() {
return {
localVal: this.value,
currentView: 0,
selection: {
start: 0,
end: 0,
startOfBlock: 0, //block meaning first character after last linefeed before or at start of selection
endOfBlock: 0, //end of block meaning selection expanded to end of line (unless there isn't one)
hasSelection: false
},
editAreaHeight: 300,
view: {
HIDDEN_VIEW: 0,
SPLIT_VIEW: 1,
WIKI_VIEW: 2,
DESIGN_VIEW: 3
},
tableMenu: false,
tableMenuColumns: 2,
tableMenuRows: 2,
linkMenu: false,
menuX: 10,
menuY: 10,
linkUrl: "",
linkText: "",
imageMenu: false,
imageTab: null,
imageUrl: "",
imageText: "",
selectedImageAttachment: null
};
},
props: {
value: String,
readOnly: Boolean,
attachments: Array
},
watch: {
value(value) {
this.localVal = value;
}
},
methods: {
compiledOutput() {
if (this.localVal.length == 0) {
return "";
}
return DOMPurify.sanitize(marked(this.localVal, { breaks: true }));
},
onResize() {
// this.editAreaHeight = window.innerHeight / 2;
this.editAreaHeight = window.innerHeight * 0.8;
},
editStyle() {
if (this.currentView == this.view.SPLIT_VIEW) {
return "height: " + this.editAreaHeight + "px;";
} else {
return false; //false attributes don't get rendered
}
},
wikiStyle() {
if (this.currentView == this.view.SPLIT_VIEW) {
return "height: " + this.editAreaHeight + "px;overflow-y:auto;";
} else {
return false; //false attributes don't get rendered
}
},
getSelectedRange(forceBlock) {
let bodyTextArea = this.$refs.editArea.$el.querySelector("textarea");
this.selection.start = bodyTextArea.selectionStart;
this.selection.end = bodyTextArea.selectionEnd;
//some edits only work on a block so if user is just clicked on a line then add make a selection
if (forceBlock) {
//add a character to selection forward if possible but if not then go backward one
if (this.selection.end < this.localVal.length) {
this.selection.end++;
} else {
if (this.selection.start > 0) {
this.selection.start--;
}
}
}
this.selection.hasSelection = this.selection.start != this.selection.end;
//block selection
//start of block...
//default
this.selection.startOfBlock = this.selection.start;
if (this.selection.start > 0) {
//find linefeed prior to current start
let indexOfLineFeed = this.localVal.lastIndexOf(
"\n",
this.selection.start
);
if (indexOfLineFeed != -1) {
this.selection.startOfBlock = indexOfLineFeed + 1;
} else {
//this may be wrong but if it's the first line then I think the block should be the first character
this.selection.startOfBlock = 0;
}
}
//end of block
//default
this.selection.endOfBlock = this.selection.end;
if (this.selection.end > 0) {
//find linefeed prior to current start
let indexOfLineFeed = this.localVal.indexOf("\n", this.selection.end);
if (indexOfLineFeed != -1) {
this.selection.endOfBlock = indexOfLineFeed;
}
}
},
setSelectedRange(start, end) {
let bodyTextArea = this.$refs.editArea.$el.querySelector("textarea");
bodyTextArea.setSelectionRange(start, end);
},
getSelectedText() {
let selectedText = "";
if (this.selection.hasSelection) {
selectedText = this.localVal.substring(
this.selection.start,
this.selection.end
);
}
return selectedText;
},
replaceSelectedText(newString) {
this.localVal = window.$gz.util.stringSplice(
this.localVal,
this.selection.start,
this.selection.end - this.selection.start,
newString
);
},
getSelectedBlock() {
let selectedText = "";
if (this.selection.hasSelection) {
selectedText = this.localVal.substring(
this.selection.startOfBlock,
this.selection.endOfBlock
);
}
return selectedText; //.trim();
},
replaceSelectedBlock(newString) {
this.localVal = window.$gz.util.stringSplice(
this.localVal,
this.selection.startOfBlock,
this.selection.endOfBlock - this.selection.startOfBlock,
newString
);
},
handleDoubleClick(i) {
//the purpose of this is only to change the selection if it's got an extra space to the right
//because double clicking on a word with another word after it causes the space to be included
this.getSelectedRange();
let temp = this.getSelectedText();
let tempTrimmed = temp.trimEnd();
let diff = temp.length - tempTrimmed.length;
if (diff != 0) {
//there were some spaces so update the selection range
//force selection to be shorter by diff
this.setSelectedRange(this.selection.start, this.selection.end - diff);
}
},
handleInput(val) {
this.$emit("input", val);
this.localVal = val;
},
switchViewIcon() {
//return the icon that indicates what it will change to if you click it
//mirror of switchview below
if (this.readOnly) {
if (this.currentView == this.view.HIDDEN_VIEW) {
return "fa-eye";
} else {
return "fa-eye-slash";
}
return;
}
switch (this.currentView) {
case this.view.HIDDEN_VIEW:
return "fa-eye";
case this.view.WIKI_VIEW:
return "fa-columns";
case this.view.SPLIT_VIEW:
return "fa-feather";
case this.view.DESIGN_VIEW:
return "fa-eye";
}
},
showWiki() {
return (
this.currentView == this.view.WIKI_VIEW ||
this.currentView == this.view.SPLIT_VIEW
);
},
showDesigner() {
return (
this.currentView == this.view.DESIGN_VIEW ||
this.currentView == this.view.SPLIT_VIEW
);
},
switchView() {
//if user can't edit then cycle between hidden and wiki view
if (this.readOnly) {
if (this.currentView == this.view.HIDDEN_VIEW) {
this.currentView = this.view.WIKI_VIEW;
} else {
this.currentView = this.view.HIDDEN_VIEW;
}
return;
}
//user can edit so switch OUT of hidden view but never into it
//and cycle between design, split and wiki views only
//never goes into hidden
switch (this.currentView) {
case this.view.HIDDEN_VIEW:
this.currentView = this.view.WIKI_VIEW;
break;
case this.view.WIKI_VIEW:
this.currentView = this.view.SPLIT_VIEW;
break;
case this.view.SPLIT_VIEW:
this.currentView = this.view.DESIGN_VIEW;
break;
case this.view.DESIGN_VIEW:
this.currentView = this.view.WIKI_VIEW;
break;
}
},
visibleIcon() {
return this.wikiVisible ? "fa-eye-slash" : "fa-eye";
},
//EDITING
editClick(editType, ex) {
if (editType != "heading") {
this.getSelectedRange();
}
switch (editType) {
case "bold":
this.replaceSelectedText("**" + this.getSelectedText() + "**");
break;
case "italic":
this.replaceSelectedText("*" + this.getSelectedText() + "*");
break;
case "strike":
this.replaceSelectedText("~~" + this.getSelectedText() + "~~");
break;
case "line":
this.replaceSelectedText("***");
break;
case "heading":
{
this.getSelectedRange(true); //special forces
let prepend = "#".repeat(ex) + " ";
let s = this.getSelectedBlock();
s = s.replace(/\n/gi, "\n" + prepend);
if (s.length > 0 && s[0] != "\n") {
s = prepend + s;
}
this.replaceSelectedBlock(s);
}
break;
case "code":
this.replaceSelectedText("`" + this.getSelectedText() + "`");
break;
case "codeblock":
this.replaceSelectedBlock(
"\n```\n" + this.getSelectedBlock() + "\n```\n"
);
break;
case "quote":
if (this.selection.hasSelection) {
this.replaceSelectedBlock("\n>" + this.getSelectedBlock() + "\n");
} else {
this.replaceSelectedText("\n>");
}
break;
case "ul":
{
if (this.selection.hasSelection) {
let s = this.getSelectedBlock();
s = s.replace(/\n/gi, "\n* ");
if (s.length > 0 && s[0] != "\n") {
s = "* " + s;
}
s = s + "\n";
this.replaceSelectedBlock(s);
} else {
this.replaceSelectedText("\n* ");
}
}
break;
case "ol":
{
if (this.selection.hasSelection) {
let s = this.getSelectedBlock();
// console.log("Selected block:[" + s + "]");
let ret = "\n1. ";
let listItem = 1;
for (let i = 0; i < s.length; i++) {
if (s[i] == "\n") {
++listItem;
ret += "\n" + listItem + ". ";
} else {
ret += s[i];
}
}
ret += "\n\n";
// console.log("RET:[" + ret + "]");
this.replaceSelectedBlock(ret);
} else {
this.replaceSelectedText("\n1. ");
}
}
break;
case "task":
{
if (this.selection.hasSelection) {
let s = this.getSelectedBlock();
s = s.replace(/\n/gi, "\n* [ ] ");
if (s.length > 0 && s[0] != "\n") {
s = "* [ ] " + s;
}
s = s + "\n";
this.replaceSelectedBlock(s);
} else {
this.replaceSelectedText("\n* [ ] ");
}
}
break;
case "table":
{
this.tableMenu = false;
//| Column 1 | Column 2 | Column 3 |
//| -------- | -------- | -------- |
//| John | Doe | Male |
//| Mary | Smith | Female |
this.getSelectedRange();
let t = "\n";
//Header
//| CCC | CCC | CCC |
for (let c = 0; c < this.tableMenuColumns; c++) {
t += "| CCC ";
}
t += "|\n";
//Divider
//| --- | --- | --- |
for (let c = 0; c < this.tableMenuColumns; c++) {
t += "| --- ";
}
t += "|\n";
//Rows
//| XXX | XXX | XXX |
for (let r = 0; r < this.tableMenuRows; r++) {
for (let c = 0; c < this.tableMenuColumns; c++) {
t += "| XXX ";
}
t += "|\n";
}
this.replaceSelectedText(t);
}
break;
case "link":
{
this.linkMenu = false;
let url = this.linkUrl;
if (!url) {
return;
}
//force it to a full url so it doesn't attempt to open it as a SPA window
if (!url.includes(":")) {
url = "https://" + url;
}
let t = "[" + this.linkText + "](" + url + ")";
// [MY Awesome LINK](www.ayanova.com)
this.replaceSelectedText(t);
}
break;
case "image":
//![alttexthere](https://www.ayanova.com/images/AyaNovaIcon256.png "title text here (tooltip)")
{
if (ex == 1) {
throw "Attachment mode TODO";
//todo: ATTACHEDFILEMODE//attached file mode?
} else {
this.imageMenu = false;
let url = this.imageUrl;
if (!url) {
return;
}
let txt = this.imageText;
//force it to a full url so it doesn't attempt to open it as a SPA window
if (!url.includes(":")) {
url = "https://" + url;
}
if (txt) {
this.replaceSelectedText(
"![" + txt + "](" + url + ' "' + txt + '") \n' + txt //keep original selected text otherwise it will vanish
);
} else {
this.replaceSelectedText("![](" + url + ")");
}
}
}
break;
default:
throw editType + " NOT IMPLEMENTED";
break;
}
//emit input event to parent form for dirty tracking
this.handleInput(this.localVal);
},
openLinkMenu(e) {
e.preventDefault();
this.linkMenu = false;
this.getSelectedRange();
this.linkText = this.getSelectedText();
this.menuX = e.clientX;
this.menuY = e.clientY;
this.$nextTick(() => {
this.linkMenu = true;
});
},
openImageMenu(e) {
e.preventDefault();
this.imageMenu = false;
this.getSelectedRange();
this.imageText = this.getSelectedText();
this.menuX = e.clientX;
this.menuY = e.clientY;
this.$nextTick(() => {
this.imageMenu = true;
});
},
availableAttachments() {
return [
{ id: 0, name: "Stub attachment one" },
{ id: 1, name: "Stub attachment two" },
{ id: 2, name: "Stub attachment three" }
];
}
}
};
/**
todo: widget form, remove wiki menu option
todo: Add wikiContent field to form defintions at server so can hide or show in form customization
- Also for dataLists? (for reporting not grid I mean)
todo: event log type just for edit wiki?
- this is because a wiki is not a discrete object in v8 so rights follow object itself and maybe it's necessary to know when wiki was edited?
- or is this more important than some other events? Not sure.
Clean up the example markdown, go through and use mine and sprinkle in the marked sample stuff where it differs
- Make sure images ONLY come from our own server, not any other
- Maybe make a key image and put on our server for wiki example so we can if we feel like it track usage of demo data
- Put a block of emojis in it with a link to the help docs regarding emojis for extra coolness
todo: STYLE / OUTPUT CSS IMPROVEMENTS
- Check with MARKED to see what css they use or require or something, maybe I'm missing something they have on their site before I roll my own
- the TOAST one is pretty cool looking
- maybe whatever is used by my docs would work too
- also there are a lot on the web search for markdown style css
- CODE BLOCK: why is it indenting the start of a code block?
- TASK Style the task markdown output, it looks pretty bleak right now
I stole the css from toast it's in the notes, search for task-list-item
also maybe there's a cleaner way try a google search once you see how they did it
- TABLES currently look shitty, find a proper style for them with boxes and shit, maybe alternating background on rows etc
- STRIKETHROUGH hard to see
*/
</script>