Finish markdown interface v3

This commit is contained in:
rijkvanzanten
2021-01-14 19:34:16 -05:00
parent ca36e312c4
commit 2aeafb2bce
8 changed files with 578 additions and 63 deletions

View File

@@ -1,46 +1,234 @@
import { Ref } from '@vue/composition-api';
import { Ref, nextTick } from '@vue/composition-api';
import { Position } from 'codemirror';
import { cloneDeep } from 'lodash';
type Alteration = 'bold' | 'italic' | 'strikethrough';
type Alteration =
| 'bold'
| 'italic'
| 'strikethrough'
| 'listBulleted'
| 'listNumbered'
| 'heading'
| 'blockquote'
| 'code'
| 'link';
type AlterationFunctions = Record<Alteration, (selections: string[]) => string[]>;
type AlterationFunctions = Record<
Alteration,
(
selections: string,
cursors: { cursorHead: Position; cursorFrom: Position; cursorTo: Position }
) => { newSelection: string; newCursor: Position; highlight?: { from: Position; to: Position } }
>;
export function useEdit(codemirror: Ref<CodeMirror.EditorFromTextArea | null>) {
const alterations: AlterationFunctions = {
bold(selections) {
return selections.map((selection) => {
if (selection.startsWith('**') && selection.endsWith('**')) {
return selection.substring(2, selection.length - 2);
} else {
return `**${selection}**`;
}
});
heading(selection, { cursorTo }) {
let newSelection = selection;
let newCursor = cursorTo;
if (selection.startsWith('# ')) {
newSelection = selection.substring(2);
} else {
newSelection = `# ${selection}`;
newCursor.ch = newCursor.ch + 2;
}
return { newSelection, newCursor };
},
italic(selections) {
return selections.map((selection) => {
if (selection.startsWith('*') && selection.endsWith('*')) {
return selection.substring(1, selection.length - 1);
} else {
return `*${selection}*`;
}
});
bold(selection, { cursorTo }) {
let newSelection = selection;
let newCursor = cursorTo;
if (selection.startsWith('**') && selection.endsWith('**')) {
newSelection = selection.substring(2, selection.length - 2);
} else {
newSelection = `**${selection}**`;
newCursor.ch = newCursor.ch + 2;
}
return { newSelection, newCursor };
},
strikethrough(selections) {
return selections.map((selection) => {
if (selection.startsWith('~~') && selection.endsWith('~~')) {
return selection.substring(2, selection.length - 2);
italic(selection, { cursorTo }) {
let newSelection = selection;
let newCursor = cursorTo;
if (selection.startsWith('*') && selection.endsWith('*')) {
newSelection = selection.substring(1, selection.length - 1);
} else {
newSelection = `*${selection}*`;
newCursor.ch = newCursor.ch + 1;
}
return { newSelection, newCursor };
},
strikethrough(selection, { cursorTo }) {
let newSelection = selection;
let newCursor = cursorTo;
if (selection.startsWith('~~') && selection.endsWith('~~')) {
newSelection = selection.substring(2, selection.length - 2);
} else {
newSelection = `~~${selection}~~`;
newCursor.ch = newCursor.ch + 2;
}
return { newSelection, newCursor };
},
listBulleted(selection, { cursorTo }) {
let newSelection = selection;
let newCursor = cursorTo;
const lines = selection.split('\n');
const isList = lines.every((line) => line.startsWith('- '));
if (isList) {
newSelection = lines.map((line) => line.substring(2)).join('\n');
} else {
newSelection = lines.map((line) => `- ${line}`).join('\n');
}
if (!selection) {
newCursor.ch = newCursor.ch + 2;
}
return { newSelection, newCursor };
},
listNumbered(selection, { cursorTo }) {
let newSelection = selection;
let newCursor = cursorTo;
const lines = selection.split('\n');
const isList = lines.every((line, index) => line.startsWith(`${index + 1}.`));
if (isList) {
newSelection = lines.map((line) => line.substring(3)).join('\n');
} else {
newSelection = lines.map((line, index) => `${index + 1}. ${line}`).join('\n');
}
if (!selection) {
newCursor.ch = newCursor.ch + 2;
}
return { newSelection, newCursor };
},
blockquote(selection, { cursorTo }) {
let newSelection = selection;
let newCursor = cursorTo;
const lines = selection.split('\n');
const isList = lines.every((line) => line.startsWith('> '));
if (isList) {
newSelection = lines.map((line) => line.substring(2)).join('\n');
} else {
newSelection = lines.map((line) => `> ${line}`).join('\n');
}
if (!selection) {
newCursor.ch = newCursor.ch + 2;
}
return { newSelection, newCursor };
},
code(selection, { cursorTo }) {
if (selection.includes('\n')) {
// Multiline
let newSelection = selection;
let newCursor = cursorTo;
if (selection.startsWith('```') && selection.endsWith('```')) {
newSelection = selection.substring(3, selection.length - 3);
} else {
return `~~${selection}~~`;
newSelection = '```\n' + newSelection + '\n```';
newCursor.line = newCursor.line + 1;
}
});
}
}
return { newSelection, newCursor };
} else {
// Inline
let newSelection = selection;
let newCursor = cursorTo;
if (selection.startsWith('`') && selection.endsWith('`')) {
newSelection = selection.substring(1, selection.length - 1);
} else {
newSelection = `\`${selection}\``;
newCursor.ch = newCursor.ch + 1;
}
return { newSelection, newCursor };
}
},
link(selection, { cursorFrom, cursorTo }) {
let newSelection = selection;
let newCursor = cursorTo;
let highlight;
if (selection.endsWith('](url)')) {
newSelection = selection.substring(1, selection.length - 6);
} else if (selection.startsWith('http')) {
newSelection = `[](${selection})`;
newCursor.ch = cursorFrom.ch + 1;
} else {
newSelection = `[${selection}](url)`;
if (selection) {
highlight = {
from: {
...cloneDeep(newCursor),
ch: newCursor.ch + 3,
},
to: {
...cloneDeep(newCursor),
ch: newCursor.ch + 6,
},
};
} else {
newCursor.ch = cursorFrom.ch + 1;
}
}
return { newSelection, newCursor, highlight };
},
};
return { edit };
function edit(type: Alteration) {
if (codemirror.value) {
const selections = codemirror.value.getSelections();
codemirror.value.replaceSelection(alterations[type](selections));
const cursor = codemirror.value.getCursor('head');
const cursorFrom = codemirror.value.getCursor('from');
const cursorTo = codemirror.value.getCursor('to');
const wordRange = codemirror.value.findWordAt(cursor);
const word = codemirror.value.getRange(wordRange.anchor, wordRange.head).trim();
const selection = codemirror.value.getSelection();
const { newSelection, newCursor, highlight } = alterations[type](selection || word, {
cursorFrom: cloneDeep(selection ? cursorFrom : wordRange.anchor),
cursorTo: cloneDeep(selection ? cursorTo : wordRange.head),
cursorHead: cursor,
});
if (word && !selection) {
codemirror.value.replaceRange(newSelection, wordRange.anchor, wordRange.head);
} else {
codemirror.value.replaceSelection(newSelection);
}
codemirror.value.setCursor(newCursor);
if (highlight) {
codemirror.value.setSelection(highlight.from, highlight.to);
}
codemirror.value.focus();
}
}
}

View File

@@ -1,19 +1,34 @@
<template>
<div class="interface-markdown">
<div class="interface-markdown" :class="view[0]">
<div class="toolbar">
<v-button icon @click="edit('bold')"><v-icon name="format_bold" /></v-button>
<v-button icon @click="edit('italic')"><v-icon name="format_italic" /></v-button>
<v-button icon @click="edit('strikethrough')"><v-icon name="format_strikethrough" /></v-button>
<v-button small icon @click="edit('heading')"><v-icon name="title" /></v-button>
<v-button small icon @click="edit('bold')"><v-icon name="format_bold" /></v-button>
<v-button small icon @click="edit('italic')"><v-icon name="format_italic" /></v-button>
<v-button small icon @click="edit('strikethrough')"><v-icon name="format_strikethrough" /></v-button>
<v-button small icon @click="edit('listBulleted')"><v-icon name="format_list_bulleted" /></v-button>
<v-button small icon @click="edit('listNumbered')"><v-icon name="format_list_numbered" /></v-button>
<v-button small icon @click="edit('blockquote')"><v-icon name="format_quote" /></v-button>
<v-button small icon @click="edit('code')"><v-icon name="code" /></v-button>
<v-button small icon @click="edit('link')"><v-icon name="insert_link" /></v-button>
<div class="spacer"></div>
<v-button-group class="view" mandatory v-model="view" rounded>
<v-button x-small value="editor">Editor</v-button>
<v-button x-small value="preview">Preview</v-button>
</v-button-group>
</div>
<textarea ref="codemirrorEl" :value="value || ''" />
<div class="preview"></div>
<div v-show="view[0] === 'preview'" class="preview-box" v-html="html"></div>
</div>
</template>
<script lang="ts">
import { defineComponent, computed, ref, onMounted, onUnmounted } from '@vue/composition-api';
import { defineComponent, computed, ref, onMounted, onUnmounted, watch } from '@vue/composition-api';
import { sanitize } from 'dompurify';
import marked from 'marked';
import CodeMirror from 'codemirror';
import 'codemirror/mode/markdown/markdown';
@@ -35,17 +50,27 @@ export default defineComponent({
default: null,
},
},
setup(props) {
setup(props, { emit }) {
const codemirrorEl = ref<HTMLTextAreaElement | null>(null);
const codemirror = ref<CodeMirror.EditorFromTextArea | null>(null);
const view = ref(['editor']);
onMounted(async () => {
if (codemirrorEl.value) {
codemirror.value = CodeMirror.fromTextArea(codemirrorEl.value, {
mode: 'markdown'
mode: 'markdown',
configureMouse: () => ({ addNew: false }),
});
codemirror.value.setValue(props.value || '');
codemirror.value.on('change', (cm, { origin }) => {
if (origin === 'setValue') return;
const content = cm.getValue();
emit('input', content);
});
}
});
@@ -53,12 +78,259 @@ export default defineComponent({
codemirror.value?.toTextArea();
});
watch(
() => props.value,
(newValue) => {
if (codemirror.value?.getValue() !== newValue) {
codemirror.value?.setValue(props.value || '');
}
}
);
const { edit } = useEdit(codemirror);
return { codemirrorEl, edit };
const html = computed(() => {
const html = marked(props.value || '');
const htmlSanitized = sanitize(html);
return htmlSanitized;
});
return { codemirrorEl, edit, view, html };
},
});
</script>
<style lang="scss" scoped>
.interface-markdown {
--v-button-background-color: transparent;
--v-button-color: var(--foreground-normal);
--v-button-background-color-hover: var(--border-normal);
--v-button-color-hover: var(--foreground-normal);
min-height: 300px;
overflow: hidden;
border: 2px solid var(--border-normal);
border-radius: var(--border-radius);
}
textarea {
display: none;
}
.interface-markdown ::v-deep .CodeMirror {
border: none;
border-radius: 0;
.CodeMirror-lines {
padding: 0 20px;
&:first-of-type {
margin-top: 20px;
}
&:last-of-type {
margin-bottom: 20px;
}
}
.CodeMirror-scroll {
min-height: 300px - 40px;
}
}
.interface-markdown.preview {
::v-deep .CodeMirror {
display: none;
}
}
.toolbar {
display: flex;
align-items: center;
height: 40px;
padding: 0 4px;
background-color: var(--background-subdued);
border-bottom: 2px solid var(--border-normal);
.v-button + .v-button {
margin-left: 2px;
}
.spacer {
flex-grow: 1;
}
.view {
--v-button-background-color: var(--border-subdued);
--v-button-color: var(--foreground-subdued);
--v-button-background-color-hover: var(--border-normal);
--v-button-color-hover: var(--foreground-normal);
--v-button-background-color-activated: var(--border-normal);
--v-button-color-activated: var(--foreground-normal);
}
}
.preview-box {
padding: 20px;
::v-deep {
h1 {
margin-bottom: 0;
font-weight: 300;
font-size: 44px;
font-family: var(--font-serif), serif;
line-height: 52px;
}
h2 {
margin-top: 60px;
margin-bottom: 0;
font-weight: 600;
font-size: 34px;
line-height: 38px;
}
h3 {
margin-top: 40px;
margin-bottom: 0;
font-weight: 600;
font-size: 26px;
line-height: 31px;
}
h4 {
margin-top: 40px;
margin-bottom: 0;
font-weight: 600;
font-size: 22px;
line-height: 28px;
}
h5 {
margin-top: 40px;
margin-bottom: 0;
font-weight: 600;
font-size: 18px;
line-height: 26px;
}
h6 {
margin-top: 40px;
margin-bottom: 0;
font-weight: 600;
font-size: 16px;
line-height: 24px;
}
p {
margin-top: 20px;
margin-bottom: 20px;
font-size: 16px;
font-family: var(--font-serif), serif;
line-height: 32px;
}
a {
color: #546e7a;
}
ul,
ol {
margin: 24px 0;
font-size: 18px;
font-family: var(--font-serif), serif;
line-height: 34px;
}
ul ul,
ol ol,
ul ol,
ol ul {
margin: 0;
}
b,
strong {
font-weight: 600;
}
code {
padding: 2px 4px;
font-size: 18px;
font-family: var(--family-monospace), monospace;
line-height: 34px;
overflow-wrap: break-word;
background-color: #eceff1;
border-radius: 4px;
}
pre {
padding: 20px;
overflow: auto;
font-size: 18px;
font-family: var(--family-monospace), monospace;
line-height: 24px;
background-color: #eceff1;
border-radius: 4px;
}
blockquote {
margin-left: -10px;
padding-left: 10px;
font-size: 18px;
font-family: var(--font-serif), serif;
font-style: italic;
line-height: 34px;
border-left: 2px solid #546e7a;
blockquote {
margin-left: 10px;
}
}
video,
iframe,
img {
max-width: 100%;
height: auto;
border-radius: 4px;
}
hr {
margin-top: 52px;
margin-bottom: 56px;
text-align: center;
border: 0;
}
hr::after {
font-size: 28px;
line-height: 0;
letter-spacing: 16px;
content: '...';
}
table {
border-collapse: collapse;
}
table th,
table td {
padding: 0.4rem;
border: 1px solid #cfd8dc;
}
figure {
display: table;
margin: 1rem auto;
}
figure figcaption {
display: block;
margin-top: 0.25rem;
color: #999;
text-align: center;
}
}
}
</style>