Merge pull request #383 from writeas/wysiwyg

Add WYSIWYG editor option
This commit is contained in:
Matt Baer 2021-03-06 16:48:56 -05:00 committed by GitHub
commit fbb67bc9ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 17646 additions and 31 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
node_modules
*~
*.swp
*.swo

View File

@ -131,6 +131,7 @@ release-docker :
ui : force_look
cd less/; $(MAKE) $(MFLAGS)
cd prose/; $(MAKE) $(MFLAGS)
assets : generate
go-bindata -pkg writefreely -ignore=\\.gitignore -tags="!wflib" schema.sql sqlite.sql

View File

@ -5,6 +5,7 @@ all :
lessc app.less --clean-css="--s1 --advanced" $(CSSDIR)write.css
lessc fonts.less --clean-css="--s1 --advanced" $(CSSDIR)fonts.css
lessc icons.less --clean-css="--s1 --advanced" $(CSSDIR)icons.css
lessc prose.less --clean-css="--s1 --advanced" $(CSSDIR)prose.css
install :
./install-less.sh

View File

@ -7,5 +7,6 @@
@import "admin";
@import "login";
@import "pages/error";
@import "resources";
@import "lib/elements";
@import "lib/material";

View File

@ -1,17 +1,3 @@
@primary: rgb(114, 120, 191);
@secondary: rgb(114, 191, 133);
@subheaders: #444;
@headerTextColor: black;
@sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif;
@serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif;
@monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace;
@dangerCol: #e21d27;
@errUrgentCol: #ecc63c;
@proSelectedCol: #71D571;
@textLinkColor: rgb(0, 0, 238);
@accent: #767676;
body {
font-family: @serifFont;
font-size-adjust: 0.5;
@ -529,7 +515,7 @@ pre, body#post article, #post .alert, #subpage .alert, body#collection article,
line-height: 1.5;
}
}
textarea, pre, body#post article, body#collection article p {
textarea, input#title, pre, body#post article, body#collection article p {
&.norm, &.sans, &.wrap {
line-height: 1.5;
white-space: pre-wrap; /* CSS 3 */
@ -539,7 +525,7 @@ textarea, pre, body#post article, body#collection article p {
word-wrap: break-word; /* Internet Explorer 5.5+ */
}
}
textarea, pre, body#post article, body#collection article, body#subpage article, span, .font {
textarea, input#title, pre, body#post article, body#collection article, body#subpage article, span, .font {
&.norm {
font-family: @serifFont;
}

View File

@ -2,7 +2,7 @@
# Install Less via npm
if [ ! -e "$(which lessc)" ]; then
sudo npm install -g less
sudo npm install -g less@3.5.3
sudo npm install -g less-plugin-clean-css
else
echo LESS $(npm view less version 2>&1 | grep -v WARN) is installed

View File

@ -188,18 +188,18 @@ body#pad, body#pad-sub {
body#pad {
.pad-theme-transition;
textarea {
textarea, #title {
.pad-theme-transition;
}
&.dark {
textarea {
textarea, #title, #editor {
background-color: @darkBG;
color: @darkTextColor;
}
}
&.light {
textarea {
textarea, #title, #editor {
background-color: @lightBG;
color: @lightTextColor;
}

View File

@ -256,7 +256,7 @@ body#pad {
border: 0;
outline: 0;
}
textarea {
textarea, #title {
position: fixed !important;
top: 3em;
right: 0;
@ -385,6 +385,14 @@ body#pad .alert {
top: 2.25em;
padding-top: 0.25em;
}
&.classic {
#editor {
top: 5.25em;
}
#title {
top: 3.5rem;
}
}
#tools {
padding-top: 0.5em;
padding-bottom: 0.5em;
@ -438,8 +446,8 @@ body#pad .alert {
}
@media all and (min-width: 50em) {
body#pad {
textarea {
body#pad, body#pad.classic {
textarea, #title {
padding-left: 10%;
padding-right: 10%;
}
@ -450,8 +458,8 @@ body#pad .alert {
}
}
@media all and (min-width: 60em) {
body#pad {
textarea {
body#pad, body#pad.classic {
textarea, #title {
padding-left: 15%;
padding-right: 15%;
}
@ -462,8 +470,8 @@ body#pad .alert {
}
}
@media all and (min-width: 70em) {
body#pad {
textarea {
body#pad, body#pad.classic {
textarea, #title {
padding-left: 20%;
padding-right: 20%;
}
@ -474,8 +482,8 @@ body#pad .alert {
}
}
@media all and (min-width: 85em) {
body#pad {
textarea {
body#pad, body#pad.classic {
textarea, #title {
padding-left: 25%;
padding-right: 25%;
}
@ -486,8 +494,8 @@ body#pad .alert {
}
}
@media all and (min-width: 105em) {
body#pad {
textarea {
body#pad, body#pad.classic {
textarea, #title {
padding-left: 30%;
padding-right: 30%;
}

450
less/prose-editor.less Normal file
View File

@ -0,0 +1,450 @@
body#pad.classic {
header {
display: flex;
justify-content: space-between;
align-items: center;
}
#editor {
top: 4em;
}
#title {
top: 4.25rem;
bottom: unset;
height: auto;
font-weight: bold;
font-size: 2em;
padding-top: 0;
padding-bottom: 0;
border: 0;
}
#tools {
#belt {
float: none;
}
}
#target {
ul {
a {
padding: 0 0.5em !important;
}
}
}
}
.ProseMirror {
position: relative;
height: calc(~"100% - 1.6em");
overflow-y: auto;
box-sizing: border-box;
-moz-box-sizing: border-box;
font-size: 1.2em;
word-wrap: break-word;
white-space: pre-wrap;
-webkit-font-variant-ligatures: none;
font-variant-ligatures: none;
padding: 0.5em 0;
line-height: 1.5;
outline: none;
}
.ProseMirror pre {
white-space: pre-wrap;
}
.ProseMirror li {
position: relative;
}
.ProseMirror-hideselection *::selection {
background: transparent;
}
.ProseMirror-hideselection *::-moz-selection {
background: transparent;
}
.ProseMirror-hideselection {
caret-color: transparent;
}
.ProseMirror-selectednode {
outline: 2px solid #8cf;
}
/* Make sure li selections wrap around markers */
li.ProseMirror-selectednode {
outline: none;
}
li.ProseMirror-selectednode:after {
content: "";
position: absolute;
left: -32px;
right: -2px;
top: -2px;
bottom: -2px;
border: 2px solid #8cf;
pointer-events: none;
}
.ProseMirror-textblock-dropdown {
min-width: 3em;
}
.ProseMirror-menu {
margin: 0 -4px;
line-height: 1;
}
.ProseMirror-tooltip .ProseMirror-menu {
width: -webkit-fit-content;
width: fit-content;
white-space: pre;
}
.ProseMirror-menuitem {
margin-right: 3px;
display: inline-block;
div {
cursor: pointer;
}
}
.ProseMirror-menuseparator {
border-right: 1px solid #ddd;
margin-right: 3px;
}
.ProseMirror-menu-dropdown, .ProseMirror-menu-dropdown-menu {
font-size: 90%;
white-space: nowrap;
}
.ProseMirror-menu-dropdown {
vertical-align: 1px;
cursor: pointer;
position: relative;
padding-right: 15px;
}
.ProseMirror-menu-dropdown-wrap {
padding: 1px 0 1px 4px;
display: inline-block;
position: relative;
}
.ProseMirror-menu-dropdown:after {
content: "";
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 4px solid currentColor;
opacity: .6;
position: absolute;
right: 4px;
top: calc(50% - 2px);
}
.ProseMirror-menu-dropdown-menu, .ProseMirror-menu-submenu {
position: absolute;
background: white;
color: #666;
border: 1px solid #aaa;
padding: 2px;
}
.ProseMirror-menu-dropdown-menu {
z-index: 15;
min-width: 6em;
}
.ProseMirror-menu-dropdown-item {
cursor: pointer;
padding: 2px 8px 2px 4px;
}
.ProseMirror-menu-dropdown-item:hover {
background: #f2f2f2;
}
.ProseMirror-menu-submenu-wrap {
position: relative;
margin-right: -4px;
}
.ProseMirror-menu-submenu-label:after {
content: "";
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
border-left: 4px solid currentColor;
opacity: .6;
position: absolute;
right: 4px;
top: calc(50% - 4px);
}
.ProseMirror-menu-submenu {
display: none;
min-width: 4em;
left: 100%;
top: -3px;
}
.ProseMirror-menu-active {
background: #eee;
border-radius: 4px;
}
.ProseMirror-menu-active {
background: #eee;
border-radius: 4px;
}
.ProseMirror-menu-disabled {
opacity: .3;
}
.ProseMirror-menu-submenu-wrap:hover .ProseMirror-menu-submenu, .ProseMirror-menu-submenu-wrap-active .ProseMirror-menu-submenu {
display: block;
}
.ProseMirror-menubar {
position: relative;
min-height: 1em;
color: #666;
padding: 0.5em;
top: 0;
left: 0;
right: 0;
background: rgba(255, 255, 255, 0.8);
z-index: 10;
-moz-box-sizing: border-box;
box-sizing: border-box;
overflow: visible;
}
.ProseMirror-icon {
display: inline-block;
line-height: .8;
vertical-align: -2px; /* Compensate for padding */
padding: 2px 8px;
cursor: pointer;
}
.ProseMirror-menu-disabled.ProseMirror-icon {
cursor: default;
}
.ProseMirror-icon svg {
fill: currentColor;
height: 1em;
}
.ProseMirror-icon span {
vertical-align: text-top;
}
.ProseMirror-gapcursor {
display: none;
pointer-events: none;
position: absolute;
}
.ProseMirror-gapcursor:after {
content: "";
display: block;
position: absolute;
top: -2px;
width: 20px;
border-top: 1px solid black;
animation: ProseMirror-cursor-blink 1.1s steps(2, start) infinite;
}
@keyframes ProseMirror-cursor-blink {
to {
visibility: hidden;
}
}
.ProseMirror-focused .ProseMirror-gapcursor {
display: block;
}
/* Add space around the hr to make clicking it easier */
.ProseMirror-example-setup-style hr {
padding: 2px 10px;
border: none;
margin: 1em 0;
}
.ProseMirror-example-setup-style hr:after {
content: "";
display: block;
height: 1px;
background-color: silver;
line-height: 2px;
}
.ProseMirror ul, .ProseMirror ol {
padding-left: 30px;
}
.ProseMirror blockquote {
padding-left: 1em;
border-left: 3px solid #eee;
margin-left: 0;
margin-right: 0;
}
.ProseMirror-example-setup-style img {
cursor: default;
}
.ProseMirror-prompt {
background: white;
padding: 1em;
border: 1px solid silver;
position: fixed;
border-radius: 0.25em;
z-index: 11;
box-shadow: -.5px 2px 5px rgba(0, 0, 0, .2);
}
.ProseMirror-prompt h5 {
margin: 0 0 0.75em;
font-family: @sansFont;
font-size: 100%;
color: #444;
}
.ProseMirror-prompt input[type="text"],
.ProseMirror-prompt textarea {
background: #eee;
border: none;
outline: none;
}
.ProseMirror-prompt input[type="text"] {
margin: 0.25em 0;
}
.ProseMirror-prompt-close {
position: absolute;
left: 2px;
top: 1px;
color: #666;
border: none;
background: transparent;
padding: 0;
}
.ProseMirror-prompt-close:after {
content: "✕";
font-size: 12px;
}
.ProseMirror-invalid {
background: #ffc;
border: 1px solid #cc7;
border-radius: 4px;
padding: 5px 10px;
position: absolute;
min-width: 10em;
}
.ProseMirror-prompt-buttons {
margin-top: 5px;
display: none;
}
#editor, .editor {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
color: black;
background-clip: padding-box;
padding: 5px 0;
margin: 4em auto 23px auto;
}
.ProseMirror p:first-child,
.ProseMirror h1:first-child,
.ProseMirror h2:first-child,
.ProseMirror h3:first-child,
.ProseMirror h4:first-child,
.ProseMirror h5:first-child,
.ProseMirror h6:first-child {
margin-top: 10px;
}
.ProseMirror p {
margin-bottom: 1em;
}
textarea {
width: 100%;
height: 123px;
border: 1px solid silver;
box-sizing: border-box;
-moz-box-sizing: border-box;
padding: 3px 10px;
border: none;
outline: none;
font-family: inherit;
font-size: inherit;
}
.ProseMirror-menubar-wrapper {
height: 100%;
box-sizing: border-box;
}
.ProseMirror-menubar-wrapper, #markdown textarea {
display: block;
margin-bottom: 4px;
}
.editorreadmore {
color: @textLinkColor;
text-decoration: underline;
text-align: center;
width: 100%;
}
@media all and (min-width: 50em) {
#editor {
margin-left: 10%;
margin-right: 10%;
}
}
@media all and (min-width: 60em) {
#editor {
margin-left: 15%;
margin-right: 15%;
}
}
@media all and (min-width: 70em) {
#editor {
margin-left: 20%;
margin-right: 20%;
}
}
@media all and (min-width: 85em) {
#editor {
margin-left: 25%;
margin-right: 25%;
}
}
@media all and (min-width: 105em) {
#editor {
margin-left: 30%;
margin-right: 30%;
}
}

4
less/prose.less Normal file
View File

@ -0,0 +1,4 @@
@import "prose-editor";
@import "pad-theme";
@import "resources";
@import "lib/elements";

13
less/resources.less Normal file
View File

@ -0,0 +1,13 @@
@primary: rgb(114, 120, 191);
@secondary: rgb(114, 191, 133);
@subheaders: #444;
@headerTextColor: black;
@sansFont: 'Open Sans', 'Segoe UI', Tahoma, Arial, sans-serif;
@serifFont: Lora, 'Palatino Linotype', 'Book Antiqua', 'New York', 'DejaVu serif', serif;
@monoFont: Hack, consolas, Menlo-Regular, Menlo, Monaco, 'ubuntu mono', monospace, monospace;
@dangerCol: #e21d27;
@errUrgentCol: #ecc63c;
@proSelectedCol: #71D571;
@textLinkColor: rgb(0, 0, 238);
@accent: #767676;

8
prose/.babelrc.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
"presets": [
["@babel/env", {
"modules": false
}]
],
"plugins": ["@babel/plugin-syntax-dynamic-import"]
}

4
prose/.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"tabWidth": 2,
"useTabs": false
}

3
prose/Makefile Normal file
View File

@ -0,0 +1,3 @@
all :
npm install
npm run-script build

7
prose/README.md Normal file
View File

@ -0,0 +1,7 @@
# Building
* Run `npm install` to download dependencies.
* Run `npm run-script build` to build a production script in `../static/js/` or run
`npm run develop` to build and watch for changes. You can use `prose.html`
to test your development changes.
* Manually copy the file `prose.bundle.js` to `static/js/`. _To be automated_

57
prose/markdownParser.js Normal file
View File

@ -0,0 +1,57 @@
import { MarkdownParser } from "prosemirror-markdown";
import markdownit from "markdown-it";
import { writeFreelySchema } from "./schema";
export const writeAsMarkdownParser = new MarkdownParser(
writeFreelySchema,
markdownit("commonmark", { html: true }),
{
// blockquote: { block: "blockquote" },
paragraph: { block: "paragraph" },
list_item: { block: "list_item" },
bullet_list: { block: "bullet_list" },
ordered_list: {
block: "ordered_list",
getAttrs: (tok) => ({ order: +tok.attrGet("start") || 1 }),
},
heading: {
block: "heading",
getAttrs: (tok) => ({ level: +tok.tag.slice(1) }),
},
code_block: { block: "code_block", noCloseToken: true },
fence: {
block: "code_block",
getAttrs: (tok) => ({ params: tok.info || "" }),
noCloseToken: true,
},
// hr: { node: "horizontal_rule" },
image: {
node: "image",
getAttrs: (tok) => ({
src: tok.attrGet("src"),
title: tok.attrGet("title") || null,
alt: tok.children?.[0].content || null,
}),
},
hardbreak: { node: "hard_break" },
em: { mark: "em" },
strong: { mark: "strong" },
link: {
mark: "link",
getAttrs: (tok) => ({
href: tok.attrGet("href"),
title: tok.attrGet("title") || null,
}),
},
code_inline: { mark: "code", noCloseToken: true },
html_block: {
node: "readmore",
getAttrs(token) {
// TODO: Give different attributes depending on the token content
return {};
},
},
}
);

123
prose/markdownSerializer.js Normal file
View File

@ -0,0 +1,123 @@
import { MarkdownSerializer } from "prosemirror-markdown";
function backticksFor(node, side) {
const ticks = /`+/g;
let m;
let len = 0;
if (node.isText)
while ((m = ticks.exec(node.text))) len = Math.max(len, m[0].length);
let result = len > 0 && side > 0 ? " `" : "`";
for (let i = 0; i < len; i++) result += "`";
if (len > 0 && side < 0) result += " ";
return result;
}
function isPlainURL(link, parent, index, side) {
if (link.attrs.title || !/^\w+:/.test(link.attrs.href)) return false;
const content = parent.child(index + (side < 0 ? -1 : 0));
if (
!content.isText ||
content.text != link.attrs.href ||
content.marks[content.marks.length - 1] != link
)
return false;
if (index == (side < 0 ? 1 : parent.childCount - 1)) return true;
const next = parent.child(index + (side < 0 ? -2 : 1));
return !link.isInSet(next.marks);
}
export const writeAsMarkdownSerializer = new MarkdownSerializer(
{
readmore(state, node) {
state.write("<!--more-->\n");
state.closeBlock(node);
},
// blockquote(state, node) {
// state.wrapBlock("> ", undefined, node, () => state.renderContent(node));
// },
code_block(state, node) {
state.write(`\`\`\`${node.attrs.params || ""}\n`);
state.text(node.textContent, false);
state.ensureNewLine();
state.write("```");
state.closeBlock(node);
},
heading(state, node) {
state.write(`${state.repeat("#", node.attrs.level)} `);
state.renderInline(node);
state.closeBlock(node);
},
bullet_list(state, node) {
state.renderList(node, " ", () => `${node.attrs.bullet || "*"} `);
},
ordered_list(state, node) {
const start = node.attrs.order || 1;
const maxW = String(start + node.childCount - 1).length;
const space = state.repeat(" ", maxW + 2);
state.renderList(node, space, (i) => {
const nStr = String(start + i);
return `${state.repeat(" ", maxW - nStr.length) + nStr}. `;
});
},
list_item(state, node) {
state.renderContent(node);
},
paragraph(state, node) {
state.renderInline(node);
state.closeBlock(node);
},
image(state, node) {
state.write(
`![${state.esc(node.attrs.alt || "")}](${state.esc(node.attrs.src)}${
node.attrs.title ? ` ${state.quote(node.attrs.title)}` : ""
})`
);
},
hard_break(state, node, parent, index) {
for (let i = index + 1; i < parent.childCount; i += 1)
if (parent.child(i).type !== node.type) {
state.write("\\\n");
return;
}
},
text(state, node) {
state.text(node.text || "");
},
},
{
em: {
open: "*",
close: "*",
mixable: true,
expelEnclosingWhitespace: true,
},
strong: {
open: "**",
close: "**",
mixable: true,
expelEnclosingWhitespace: true,
},
link: {
open(_state, mark, parent, index) {
return isPlainURL(mark, parent, index, 1) ? "<" : "[";
},
close(state, mark, parent, index) {
return isPlainURL(mark, parent, index, -1)
? ">"
: `](${state.esc(mark.attrs.href)}${
mark.attrs.title ? ` ${state.quote(mark.attrs.title)}` : ""
})`;
},
},
code: {
open(_state, _mark, parent, index) {
return backticksFor(parent.child(index), -1);
},
close(_state, _mark, parent, index) {
return backticksFor(parent.child(index - 1), 1);
},
escape: false,
},
}
);

32
prose/menu.js Normal file
View File

@ -0,0 +1,32 @@
import { MenuItem } from "prosemirror-menu";
import { buildMenuItems } from "prosemirror-example-setup";
import { writeFreelySchema } from "./schema";
function canInsert(state, nodeType, attrs) {
let $from = state.selection.$from;
for (let d = $from.depth; d >= 0; d--) {
let index = $from.index(d);
if ($from.node(d).canReplaceWith(index, index, nodeType, attrs))
return true;
}
return false;
}
const ReadMoreItem = new MenuItem({
label: "Read more",
select: (state) => canInsert(state, writeFreelySchema.nodes.readmore),
run(state, dispatch) {
dispatch(
state.tr.replaceSelectionWith(writeFreelySchema.nodes.readmore.create())
);
},
});
export const getMenu = () => {
const menuContent = [
...buildMenuItems(writeFreelySchema).fullMenu,
[ReadMoreItem],
];
return menuContent;
};

16278
prose/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
prose/package.json Normal file
View File

@ -0,0 +1,32 @@
{
"name": "prose",
"version": "1.0.0",
"description": "",
"main": "prose.js",
"dependencies": {
"babel-core": "^6.26.3",
"babel-preset-es2015": "^6.24.1",
"markdown-it": "^12.0.4",
"prosemirror-example-setup": "^1.1.2",
"prosemirror-keymap": "^1.1.4",
"prosemirror-markdown": "github:VV-EE/prosemirror-markdown",
"prosemirror-model": "^1.9.1",
"prosemirror-state": "^1.3.2",
"prosemirror-view": "^1.14.2",
"webpack": "^4.42.0",
"webpack-cli": "^3.3.11"
},
"devDependencies": {
"@babel/core": "^7.8.7",
"@babel/preset-env": "^7.9.0",
"babel-loader": "^8.0.6",
"prettier": "^2.2.1"
},
"scripts": {
"develop": "webpack --mode development --watch",
"build": "webpack --mode production"
},
"keywords": [],
"author": "",
"license": "ISC"
}

14
prose/prose.html Normal file
View File

@ -0,0 +1,14 @@
<link rel="stylesheet" href="../static/css/prose.css" />
<div id="editor" style="margin-bottom: 0"></div>
<!-- <div style="text-align: center"> -->
<!-- <label style="border-right: 1px solid silver"> -->
<!-- Markdown <input type=radio name=inputformat value=markdown>&nbsp;</label> -->
<!-- <label>&nbsp;<input type=radio name=inputformat value=prosemirror checked> WYSIWYM</label> -->
<!-- </div> -->
<div style="display: none">
<textarea id="content">
This is a comment written in [Markdown](http://commonmark.org). *You* may know the syntax for inserting a link, but does your whole audience?&#13;&#13;So you can give people the **choice** to use a more familiar, discoverable interface.</textarea
>
</div>
<script src="dist/prose.bundle.js"></script>

118
prose/prose.js Normal file
View File

@ -0,0 +1,118 @@
// class MarkdownView {
// constructor(target, content) {
// this.textarea = target.appendChild(document.createElement("textarea"))
// this.textarea.value = content
// }
// get content() { return this.textarea.value }
// focus() { this.textarea.focus() }
// destroy() { this.textarea.remove() }
// }
import { EditorView } from "prosemirror-view";
import { EditorState, TextSelection } from "prosemirror-state";
import { exampleSetup } from "prosemirror-example-setup";
import { keymap } from "prosemirror-keymap";
import { writeAsMarkdownParser } from "./markdownParser";
import { writeAsMarkdownSerializer } from "./markdownSerializer";
import { writeFreelySchema } from "./schema";
import { getMenu } from "./menu";
let $title = document.querySelector("#title");
let $content = document.querySelector("#content");
// Bugs:
// 1. When there's just an empty line and a hard break is inserted with shift-enter then two enters are inserted
// which do not show up in the markdown ( maybe bc. they are training enters )
class ProseMirrorView {
constructor(target, content) {
let typingTimer;
let localDraft = localStorage.getItem(window.draftKey);
if (localDraft != null) {
content = localDraft;
}
if (content.indexOf("# ") === 0) {
let eol = content.indexOf("\n");
let title = content.substring("# ".length, eol);
content = content.substring(eol + "\n\n".length);
$title.value = title;
}
const doc = writeAsMarkdownParser.parse(
// Replace all "solo" \n's with \\\n for correct markdown parsing
// Can't use lookahead or lookbehind because it's not supported on Safari
content.replace(/([^]{0,1})(\n)([^]{0,1})/g, (match, p1, p2, p3) => {
return p1 !== "\n" && p3 !== "\n" ? p1 + "\\\n" + p3 : match;
})
);
this.view = new EditorView(target, {
state: EditorState.create({
doc,
plugins: [
keymap({
"Mod-Enter": () => {
document.getElementById("publish").click();
return true;
},
"Mod-k": () => {
const linkButton = document.querySelector(
".ProseMirror-icon[title='Add or remove link']"
);
linkButton.dispatchEvent(new Event("mousedown"));
return true;
},
}),
...exampleSetup({
schema: writeFreelySchema,
menuContent: getMenu(),
}),
],
}),
dispatchTransaction(transaction) {
let newState = this.state.apply(transaction);
const newContent = writeAsMarkdownSerializer
.serialize(newState.doc)
// Replace all \\\ns ( not followed by a \n ) with \n
.replace(/(\\\n)(\n{0,1})/g, (match, p1, p2) =>
p2 !== "\n" ? "\n" + p2 : match
);
$content.value = newContent;
let draft = "";
if ($title.value != null && $title.value !== "") {
draft = "# " + $title.value + "\n\n";
}
draft += newContent;
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
this.updateState(newState);
},
});
// Editor is focused to the last position. This is a workaround for a bug:
// 1. 1 type something in an existing entry
// 2. reload - works fine, the draft is reloaded
// 3. reload again - the draft is somehow removed from localStorage and the original content is loaded
// When the editor is focused the content is re-saved to localStorage
// This is also useful for editing, so it's not a bad thing even
const lastPosition = this.view.state.doc.content.size;
const selection = TextSelection.create(this.view.state.doc, lastPosition);
this.view.dispatch(this.view.state.tr.setSelection(selection));
this.view.focus();
}
get content() {
return defaultMarkdownSerializer.serialize(this.view.state.doc);
}
focus() {
this.view.focus();
}
destroy() {
this.view.destroy();
}
}
let place = document.querySelector("#editor");
let view = new ProseMirrorView(place, $content.value);

21
prose/schema.js Normal file
View File

@ -0,0 +1,21 @@
import { schema } from "prosemirror-markdown";
import { Schema } from "prosemirror-model";
export const writeFreelySchema = new Schema({
nodes: schema.spec.nodes
.remove("blockquote")
.remove("horizontal_rule")
.addToEnd("readmore", {
inline: false,
content: "",
group: "block",
draggable: true,
toDOM: (node) => [
"div",
{ class: "editorreadmore" },
"Read more...",
],
parseDOM: [{ tag: "div.editorreadmore" }],
}),
marks: schema.spec.marks,
});

25
prose/webpack.config.js Normal file
View File

@ -0,0 +1,25 @@
const path = require('path')
module.exports = {
entry: {
entry: __dirname + '/prose.js'
},
output: {
filename: 'prose.bundle.js',
path: path.resolve('..', 'static', 'js'),
},
module: {
rules: [
{
test: /\.js$/,
exclude: /(nodue_modules|bower_components)/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env']
}
}
}
]
}
}

View File

@ -110,12 +110,24 @@ Element.prototype.show = function() {
var H = {
getQEl: function(elementQuery) {
return new Element(document.querySelector(elementQuery));
},
getEl: function(elementId) {
return new Element(document.getElementById(elementId));
},
save: function($el, key) {
localStorage.setItem(key, $el.el.value);
},
saveClassic: function($titleEl, $el, key) {
var out = "";
var title = $titleEl.el.value;
if (title !== "") {
out = "# "+title+"\n\n";
}
out += $el.el.value;
localStorage.setItem(key, out);
},
load: function($el, key, onlyLoadPopulated, postUpdated) {
var val = localStorage.getItem(key);
if (onlyLoadPopulated && val == null) {
@ -138,6 +150,20 @@ var H = {
}
return true;
},
loadClassic: function($titleEl, $el, key, onlyLoadPopulated) {
var val = localStorage.getItem(key);
if (onlyLoadPopulated && val == null) {
// Do nothing
return;
}
if (val.indexOf("# ") === 0) {
var eol = val.indexOf("\n");
title = val.substring("# ".length, eol);
val = val.substring(eol+"\n\n".length);
$titleEl.el.value = title;
}
$el.el.value = val;
},
set: function(key, value) {
localStorage.setItem(key, value);
},

File diff suppressed because one or more lines are too long

400
templates/wysiwyg.tmpl Normal file
View File

@ -0,0 +1,400 @@
{{define "pad"}}<!DOCTYPE HTML>
<html>
<head>
<title>{{if .Editing}}Editing {{if .Post.Title}}{{.Post.Title}}{{else}}{{.Post.Id}}{{end}}{{else}}New Post{{end}} &mdash; {{.SiteName}}</title>
<link rel="stylesheet" type="text/css" href="/css/write.css" />
<link rel="stylesheet" type="text/css" href="/css/prose.css" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="google" value="notranslate">
</head>
<body id="pad" class="light classic">
<div id="overlay"></div>
<!-- <div style="text-align: center"> -->
<!-- <label style="border-right: 1px solid silver"> -->
<!-- Markdown <input type=radio name=inputformat value=markdown checked>&nbsp;</label> -->
<!-- <label>&nbsp;<input type=radio name=inputformat value=prosemirror> WYSIWYM</label> -->
<!-- </div> -->
<input type="text" id="title" name="title" placeholder="Title..." {{if .Post.Title}}value="{{.Post.Title}}"{{end}} autofocus />
<div id="editor" style="margin-bottom: 0"></div>
<div style="display: none"><textarea id="content"{{if .Post.Content }} value={{.Post.Content}}>{{.Post.Content}}{{else}}>{{end}}</textarea></div>
<header id="tools">
<div id="clip">
{{if not .SingleUser}}<h1><a href="/me/c/" title="View blogs"><img class="ic-24dp" src="/img/ic_blogs_dark@2x.png" /></a></h1>{{end}}
<nav id="target" {{if .SingleUser}}style="margin-left:0"{{end}}><ul>
{{if .Editing}}<li>{{if .EditCollection}}<a href="{{.EditCollection.CanonicalURL}}">{{.EditCollection.Title}}</a>{{else}}<a>Draft</a>{{end}}</li>
{{else}}<li><a id="publish-to"><span id="target-name">Draft</span> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /></a>
<ul>
<li class="menu-heading">Publish to...</li>
{{if .Blogs}}{{range $idx, $el := .Blogs}}
<li class="target{{if eq $idx 0}} selected{{end}}" id="blog-{{$el.Alias}}"><a href="#{{$el.Alias}}"><i class="material-icons md-18">public</i> {{if $el.Title}}{{$el.Title}}{{else}}{{$el.Alias}}{{end}}</a></li>
{{end}}{{end}}
<li class="target" id="blog-anonymous"><a href="#anonymous"><i class="material-icons md-18">description</i> <em>Draft</em></a></li>
<li id="user-separator" class="separator"><hr /></li>
{{ if .SingleUser }}
<li><a href="/"><i class="material-icons md-18">launch</i> View Blog</a></li>
<li><a href="/me/c/{{.Username}}"><i class="material-icons md-18">palette</i> Customize</a></li>
<li><a href="/me/c/{{.Username}}/stats"><i class="material-icons md-18">trending_up</i> Stats</a></li>
{{ else }}
<li><a href="/me/c/"><i class="material-icons md-18">library_books</i> View Blogs</a></li>
{{ end }}
<li><a href="/me/posts/"><i class="material-icons md-18">view_list</i> View Drafts</a></li>
<li><a href="/me/logout"><i class="material-icons md-18">power_settings_new</i> Log out</a></li>
</ul>
</li>{{end}}
</ul></nav>
<nav id="font-picker" class="if-room room-3 hidden" style="margin-left:-1em"><ul>
<li><a href="#" id="" onclick="return false"><img class="ic-24dp" src="/img/ic_font_dark@2x.png" /> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /></a>
<ul style="text-align: center">
<li class="menu-heading">Font</li>
<li class="selected"><a class="font norm" href="#norm">Serif</a></li>
<li><a class="font sans" href="#sans">Sans-serif</a></li>
<li><a class="font wrap" href="#wrap">Monospace</a></li>
</ul>
</li>
</ul></nav>
<span id="wc" class="hidden if-room room-4">0 words</span>
</div>
<noscript style="margin-left: 2em;"><strong>NOTE</strong>: for now, you'll need Javascript enabled to post.</noscript>
<div id="belt">
{{if .Editing}}<div class="tool hidden if-room"><a href="{{if .EditCollection}}{{.EditCollection.CanonicalURL}}{{.Post.Slug}}/edit/meta{{else}}/{{if .SingleUser}}d/{{end}}{{.Post.Id}}/meta{{end}}" title="Edit post metadata" id="edit-meta"><img class="ic-24dp" src="/img/ic_info_dark@2x.png" /></a></div>{{end}}
<div class="tool hidden if-room room-2"><a href="#theme" title="Toggle theme" id="toggle-theme"><img class="ic-24dp" src="/img/ic_brightness_dark@2x.png" /></a></div>
<div class="tool if-room room-1"><a href="{{if not .User}}/pad/posts{{else}}/me/posts/{{end}}" title="View posts" id="view-posts"><img class="ic-24dp" src="/img/ic_list_dark@2x.png" /></a></div>
<div class="tool"><a href="#publish" title="Publish" id="publish"><img class="ic-24dp" src="/img/ic_send_dark@2x.png" /></a></div>
</div>
</header>
<script src="/js/h.js"></script>
<script>
function toggleTheme() {
var btns = Array.prototype.slice.call(document.getElementById('tools').querySelectorAll('a img'));
var newTheme = '';
if (document.body.classList.contains('light')) {
newTheme = 'dark';
document.body.className = document.body.className.replace(/(?:^|\s)light(?!\S)/g, newTheme);
for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('_dark@2x.png', '@2x.png');
}
} else {
TextnewTheme = 'light';
document.body.className = document.body.className.replace(/(?:^|\s)dark(?!\S)/g, newTheme);
for (var i=0; i<btns.length; i++) {
btns[i].src = btns[i].src.replace('@2x.png', '_dark@2x.png');
}
}
H.set('padTheme', newTheme);
}
if (H.get('padTheme', 'light') != 'light') {
toggleTheme();
}
var $title = H.getEl('title');
var $writer = H.getQEl('div.ProseMirror');
var $content = H.getEl('content');
var $btnPublish = H.getEl('publish');
var $wc = H.getEl("wc");
var updateWordCount = function() {
var words = 0;
var val = $content.el.value.trim();
if (val != '') {
words = $content.el.value.trim().replace(/\s+/gi, ' ').split(' ').length;
}
val = $title.el.value.trim();
if (val != '') {
words += $title.el.value.trim().replace(/\s+/gi, ' ').split(' ').length;
}
$wc.el.innerText = words + " word" + (words != 1 ? "s" : "");
};
var setButtonStates = function() {
if (!canPublish) {
$btnPublish.el.className = 'disabled';
return;
}
if ($content.el.value.length === 0 || (draftDoc != 'lastDoc' && $content.el.value == origDoc)) {
$btnPublish.el.className = 'disabled';
} else {
$btnPublish.el.className = '';
}
};
{{if .Post.Id}}var draftDoc = 'draft{{.Post.Id}}';
var origDoc = '{{.Post.Content}}';{{else}}var draftDoc = 'lastDoc';{{end}}
// ProseMirror editor
window.draftKey = draftDoc;
// H.loadClassic($title, $writer, draftDoc, true);
updateWordCount();
var typingTimer;
var doneTypingInterval = 200;
var posts;
{{if and .Post.Id (not .Post.Slug)}}
var token = null;
var curPostIdx;
posts = JSON.parse(H.get('posts', '[]'));
for (var i=0; i<posts.length; i++) {
if (posts[i].id == "{{.Post.Id}}") {
token = posts[i].token;
break;
}
}
var canPublish = token != null;
{{else}}var canPublish = true;{{end}}
var publishing = false;
var justPublished = false;
var silenced = {{.Silenced}};
var publish = function(title, content, font) {
if (silenced === true) {
alert("Your account is silenced, so you can't publish or update posts.");
return;
}
{{if and (and .Post.Id (not .Post.Slug)) (not .User)}}
if (!token) {
alert("You don't have permission to update this post.");
return;
}
if ($btnPublish.el.className == 'disabled') {
return;
}
{{end}}
$btnPublish.el.children[0].textContent = 'more_horiz';
publishing = true;
var xpostTarg = H.get('crosspostTarget', '[]');
var http = new XMLHttpRequest();
var lang = navigator.languages ? navigator.languages[0] : (navigator.language || navigator.userLanguage);
lang = lang.substring(0, 2);
var post = H.getTitleStrict(content);
var params = {
body: post.content,
title: title,
font: font,
lang: lang
};
{{ if .Post.Slug }}
var url = "/api/collections/{{.EditCollection.Alias}}/posts/{{.Post.Id}}";
{{ else if .Post.Id }}
var url = "/api/posts/{{.Post.Id}}";
if (typeof token === 'undefined' || !token) {
token = "";
}
params.token = token;
{{ else }}
var url = "/api/posts";
var postTarget = H.get('postTarget', 'anonymous');
if (postTarget != 'anonymous') {
url = "/api/collections/" + postTarget + "/posts";
}
params.crosspost = JSON.parse(xpostTarg);
{{ end }}
http.open("POST", url, true);
// Send the proper header information along with the request
http.setRequestHeader("Content-type", "application/json");
http.onreadystatechange = function() {
if (http.readyState == 4) {
publishing = false;
if (http.status == 200 || http.status == 201) {
data = JSON.parse(http.responseText);
id = data.data.id;
nextURL = '{{if .SingleUser}}/d{{end}}/'+id;
{{ if not .Post.Id }}
// Post created
if (postTarget != 'anonymous') {
nextURL = {{if not .SingleUser}}'/'+postTarget+{{end}}'/'+data.data.slug;
}
editToken = data.data.token;
{{ if not .User }}if (postTarget == 'anonymous') {
// Save the data
var posts = JSON.parse(H.get('posts', '[]'));
{{if .Post.Id}}var newPost = H.createPost("{{.Post.Id}}", token, content);
for (var i=0; i<posts.length; i++) {
if (posts[i].id == "{{.Post.Id}}") {
posts[i].title = newPost.title;
posts[i].summary = newPost.summary;
break;
}
}
nextURL = "/pad/posts";{{else}}posts.push(H.createPost(id, editToken, content));{{end}}
H.set('posts', JSON.stringify(posts));
}
{{ end }}
{{ end }}
justPublished = true;
if (draftDoc != 'lastDoc') {
H.remove(draftDoc);
{{if .Editing}}H.remove('draft{{.Post.Id}}font');{{end}}
} else {
H.set(draftDoc, '');
}
{{if .EditCollection}}
window.location = '{{.EditCollection.CanonicalURL}}{{.Post.Slug}}';
{{else}}
window.location = nextURL;
{{end}}
} else {
$btnPublish.el.children[0].textContent = 'send';
alert("Failed to post. Please try again.");
}
}
}
http.send(JSON.stringify(params));
};
setButtonStates();
$title.on('keydown', function(e) {
if (e.keyCode == 13) {
if (e.metaKey || e.ctrlKey) {
$btnPublish.el.click();
} else {
e.preventDefault();
$writer.el.focus();
}
}
});
/*
$writer.on('keyup input', function() {
setButtonStates();
clearTimeout(typingTimer);
typingTimer = setTimeout(doneTyping, doneTypingInterval);
}, false);
$writer.on('keydown', function(e) {
clearTimeout(typingTimer);
if (e.keyCode == 13 && (e.metaKey || e.ctrlKey)) {
$btnPublish.el.click();
}
});
*/
$btnPublish.on('click', function(e) {
e.preventDefault();
if (!publishing && $content.el.value) {
var title = $title.el.value;
var content = $content.el.value;
publish(title, content, selectedFont);
}
});
H.getEl('toggle-theme').on('click', function(e) {
e.preventDefault();
var newTheme = 'light';
if (document.body.className == 'light') {
newTheme = 'dark';
}
toggleTheme();
});
var targets = document.querySelectorAll('#target li.target a');
for (var i=0; i<targets.length; i++) {
targets[i].addEventListener('click', function(e) {
e.preventDefault();
var targetName = this.href.substring(this.href.indexOf('#')+1);
H.set('postTarget', targetName);
document.querySelector('#target li.target.selected').classList.remove('selected');
this.parentElement.classList.add('selected');
var newText = this.innerText.split(' ');
newText.shift();
document.getElementById('target-name').innerText = newText.join(' ');
});
}
var postTarget = H.get('postTarget', '{{if .Blogs}}{{$blog := index .Blogs 0}}{{$blog.Alias}}{{else}}anonymous{{end}}');
if (location.hash != '') {
postTarget = location.hash.substring(1);
// TODO: pushState to /pad (or whatever the URL is) so we live on a clean URL
location.hash = '';
}
var pte = document.querySelector('#target li.target#blog-'+postTarget+' a');
if (pte != null) {
pte.click();
} else {
postTarget = 'anonymous';
H.set('postTarget', postTarget);
}
var sansLoaded = false;
WebFontConfig = {
custom: { families: [ 'Lora:400,700:latin' ], urls: [ '/css/fonts.css' ] }
};
var loadSans = function() {
if (sansLoaded) return;
sansLoaded = true;
WebFontConfig.custom.families.push('Open+Sans:400,700:latin');
try {
(function() {
var wf=document.createElement('script');
wf.src = '/js/webfont.js';
wf.type='text/javascript';
wf.async='true';
var s=document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) {}
};
var fonts = document.querySelectorAll('nav#font-picker a.font');
for (var i=0; i<fonts.length; i++) {
fonts[i].addEventListener('click', function(e) {
e.preventDefault();
selectedFont = this.href.substring(this.href.indexOf('#')+1);
// TODO: don't change classes on the editor window
//$title.el.className = selectedFont;
//$writer.el.className = selectedFont;
document.querySelector('nav#font-picker li.selected').classList.remove('selected');
this.parentElement.classList.add('selected');
H.set('{{if .Editing}}draft{{.Post.Id}}font{{else}}padFont{{end}}', selectedFont);
if (selectedFont == 'sans') {
loadSans();
}
});
}
var selectedFont = H.get('{{if .Editing}}draft{{.Post.Id}}font{{else}}padFont{{end}}', '{{.Post.Font}}');
var sfe = document.querySelector('nav#font-picker a.font.'+selectedFont);
if (sfe != null) {
sfe.click();
}
var doneTyping = function() {
if (draftDoc == 'lastDoc' || $content.el.value != origDoc) {
H.saveClassic($title, $content, draftDoc);
updateWordCount();
}
};
window.addEventListener('beforeunload', function(e) {
if (draftDoc != 'lastDoc' && $content.el.value == origDoc) {
H.remove(draftDoc);
} else if (!justPublished) {
doneTyping();
}
});
try {
(function() {
var wf=document.createElement('script');
wf.src = '/js/webfont.js';
wf.type='text/javascript';
wf.async='true';
var s=document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(wf, s);
})();
} catch (e) {
// whatevs
}
</script>
<script src="/js/prose.bundle.js"></script>
<link href="/css/icons.css" rel="stylesheet">
</body>
</html>{{end}}