Files
raven-client/ayanova/src/components/wiki-control.vue
2020-04-18 23:55:43 +00:00

705 lines
22 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="clickBold">
<v-icon>fa-bold</v-icon></v-btn
>
<v-btn depressed tile @click="clickItalic">
<v-icon>fa-italic</v-icon></v-btn
>
<v-btn depressed tile @click="clickStrike">
<v-icon>fa-strikethrough</v-icon></v-btn
>
<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="clickHeading(1)">
<v-list-item-title
><h1>{{ $ay.t("Heading") }} 1</h1></v-list-item-title
>
</v-list-item>
<v-list-item @click="clickHeading(2)">
<v-list-item-title
><h2>{{ $ay.t("Heading") }} 2</h2></v-list-item-title
>
</v-list-item>
<v-list-item @click="clickHeading(3)">
<v-list-item-title
><h3>{{ $ay.t("Heading") }} 3</h3></v-list-item-title
>
</v-list-item>
<v-list-item @click="clickHeading(4)">
<v-list-item-title
><h4>{{ $ay.t("Heading") }} 4</h4></v-list-item-title
>
</v-list-item>
<v-list-item @click="clickHeading(5)">
<v-list-item-title
><h5>{{ $ay.t("Heading") }} 5</h5></v-list-item-title
>
</v-list-item>
<v-list-item @click="clickHeading(6)">
<v-list-item-title
><h6>{{ $ay.t("Heading") }} 6</h6></v-list-item-title
>
</v-list-item>
</v-list>
</v-menu>
<v-btn depressed tile class="ml-1" @click="clickLine">
<v-icon>fa-minus</v-icon></v-btn
>
<v-btn depressed tile @click="clickQuote">
<v-icon>fa-quote-left</v-icon></v-btn
>
<v-btn depressed tile @click="clickUl">
<v-icon>fa-list-ul</v-icon></v-btn
>
<v-btn depressed tile @click="clickOl">
<v-icon>fa-list-ol</v-icon></v-btn
>
<v-btn depressed tile @click="clickTask">
<v-icon>fa-check-square</v-icon></v-btn
>
<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="clickTable">{{
$ay.t("OK")
}}</v-btn>
</v-card-actions>
</v-card>
</v-menu>
<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="linkMenuX"
:position-y="linkMenuY"
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="clickLink">{{
$ay.t("OK")
}}</v-btn>
</v-card-actions>
</v-card>
</v-menu>
<v-btn depressed tile> <v-icon>fa-image</v-icon></v-btn>
<v-btn depressed tile class="ml-1" @click="clickCode">
<v-icon>fa-code</v-icon></v-btn
>
<v-btn depressed tile @click="clickCodeBlock">
<v-icon>fa-square-full</v-icon></v-btn
>
</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
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,
linkMenuX: 10,
linkMenuY: 10,
linkUrl: "",
linkText: ""
};
},
props: {
value: String,
readOnly: Boolean
},
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) {
debugger;
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";
},
clickBold() {
this.getSelectedRange();
this.replaceSelectedText("**" + this.getSelectedText() + "**");
},
clickItalic() {
this.getSelectedRange();
this.replaceSelectedText("*" + this.getSelectedText() + "*");
},
clickStrike() {
this.getSelectedRange();
this.replaceSelectedText("~~" + this.getSelectedText() + "~~");
},
clickHeading(h) {
this.getSelectedRange(true);
let prepend = "#".repeat(h) + " ";
// if (this.selection.hasSelection) {
let s = this.getSelectedBlock();
s = s.replace(/\n/gi, "\n" + prepend);
if (s.length > 0 && s[0] != "\n") {
s = prepend + s;
}
this.replaceSelectedBlock(s);
// } else {
// this.replaceSelectedText("\n" + prepend);
// }
},
clickLine() {
this.getSelectedRange();
this.replaceSelectedText("***");
},
clickCode() {
this.getSelectedRange();
this.replaceSelectedText("`" + this.getSelectedText() + "`");
},
clickCodeBlock() {
this.getSelectedRange();
this.replaceSelectedBlock(
"\n```\n" + this.getSelectedBlock() + "\n```\n"
);
},
clickQuote() {
this.getSelectedRange();
if (this.selection.hasSelection) {
this.replaceSelectedBlock("\n>" + this.getSelectedBlock() + "\n");
} else {
this.replaceSelectedText("\n>");
}
},
clickUl() {
//if a selected block with several lines then prepend each line
//if a selected block with one line only then prepend start of first line after last linefeed
this.getSelectedRange();
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* ");
}
},
clickOl() {
//if a selected block with several lines then prepend each line
//if a selected block with one line only then prepend start of first line after last linefeed
this.getSelectedRange();
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. ");
}
},
clickTask() {
this.getSelectedRange();
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* [ ] ");
}
},
clickTable() {
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);
},
openLinkMenu(e) {
e.preventDefault();
this.linkMenu = false;
this.getSelectedRange();
this.linkText = this.getSelectedText();
this.linkMenuX = e.clientX;
this.linkMenuY = e.clientY;
this.$nextTick(() => {
this.linkMenu = true;
});
},
clickLink() {
this.linkMenu = false;
this.getSelectedRange();
//this.linkUrl
//this.linkText
let url = this.linkUrl;
//force it to an external url
if (!url.includes(":")) {
url = "https://" + url;
}
let t = "[" + this.linkText + "](" + url + ")";
// [MY Awesome LINK](www.ayanova.com)
this.replaceSelectedText(t);
}
}
};
/**
todo: all editing controls enabled
- add image and link
- determine how local images will be handled (attachments)
baseurl setting for local images: https://marked.js.org/#/USING_ADVANCED.md#options
todo: replace all redundant clickXXX functions with a single function and a flag indicating what to do
todo: Undo / redo (ctrl-z / ctrl-y) ability?
- may need a library for this one if it's tricky to support levels of undo
todo: Add wikiContent field to form defintions at server so can hide or show in 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?
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
- 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
- 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>