Improve editor user experience (no longer WYSIWYG, fixed bugs, added view vs. edit distinction) (#3)

This makes the editor experience much better (by subjective measures). Now instead of a WYSIWYG editor, we have a markdown code editor, and we also have the ability to view documents without editing them.

While I was at it, I fixed a bug where if you didn't edit a document at all, it would save blank. This was fixed as a happenstance from the switch.

Also included here is making the UI work with Javascript disabled. If you don't have JS, you will get a textarea which allows editing the markdown directly. If you do have JS enabled, you'll get a smarter editor.

Reviewed-on: #3
This commit is contained in:
Nicole Tietz-Sokolskaya 2024-06-03 14:56:15 +00:00
parent 65ad20d197
commit 137dfa747d
20 changed files with 343 additions and 37037 deletions

2
.gitignore vendored
View file

@ -1,6 +1,6 @@
target/
kvdata/
node_modules/
*.db
*.xml
.env
static/

35
Cargo.lock generated
View file

@ -1240,6 +1240,15 @@ dependencies = [
"version_check",
]
[[package]]
name = "getopts"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
dependencies = [
"unicode-width",
]
[[package]]
name = "getrandom"
version = "0.2.15"
@ -2257,6 +2266,7 @@ dependencies = [
"free-icons",
"minijinja",
"minijinja-autoreload",
"pulldown-cmark",
"rand 0.8.5",
"redb",
"serde",
@ -2346,6 +2356,25 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "pulldown-cmark"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8746739f11d39ce5ad5c2520a9b75285310dbfe78c541ccf832d38615765aec0"
dependencies = [
"bitflags 2.5.0",
"getopts",
"memchr",
"pulldown-cmark-escape",
"unicase",
]
[[package]]
name = "pulldown-cmark-escape"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
[[package]]
name = "quanta"
version = "0.12.3"
@ -3686,6 +3715,12 @@ version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
[[package]]
name = "unicode-width"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68f5e5f3158ecfd4b8ff6fe086db7c8467a2dfdac97fe420f2b7c4aa97af66d6"
[[package]]
name = "unicode_categories"
version = "0.1.1"

View file

@ -23,6 +23,7 @@ env_logger = "0.11.3"
free-icons = "0.7.0"
minijinja = { version = "1.0.14", features = ["loader", "json", "builtins"] }
minijinja-autoreload = "1.0.14"
pulldown-cmark = "0.11.0"
rand = "0.8.5"
redb = "2.1.0"
serde = { version = "1.0.197", features = ["derive"] }

40
frontend/editor.ts Normal file
View file

@ -0,0 +1,40 @@
import {basicSetup} from "codemirror"
import {EditorView, keymap} from "@codemirror/view"
import {indentWithTab} from "@codemirror/commands"
import {markdown} from "@codemirror/lang-markdown"
export function makeEditor(divSelector, value) {
let div = document.querySelector(divSelector);
let documentTheme = EditorView.theme({
"&": {
"background-color": "white",
},
".cm-editor": {
"height": "100%",
},
".cm-scroller": {overflow: "auto"}
}, {})
// add a hidden textarea inside the div for form submission
let textarea = document.createElement("textarea");
textarea.setAttribute("name", "content");
textarea.style.display = "none";
div.appendChild(textarea);
let extensions = [
basicSetup,
keymap.of([indentWithTab]),
markdown(),
documentTheme,
EditorView.lineWrapping,
];
let view = new EditorView({parent: div, doc: value, extensions})
textarea.form.addEventListener("submit", () => {
textarea.value = view.state.doc.toString()
})
return view
}

View file

@ -2,59 +2,16 @@
@tailwind components;
@tailwind utilities;
/* Borrowed from https://github.com/Milkdown/milkdown/blob/main/e2e/src/list-item-block/style.css
which is licensed under MIT. */
.prose :where(li):not(:where([class~="not-prose"] *)) {
margin-top: 0.5em;
margin-bottom: 0;
.prose li {
margin-top: 0;
margin-bottom: 0;
}
.prose :where(blockquote):not(:where([class~="not-prose"] *)) {
font-style: inherit;
font-weight: inherit;
}
.prose :where(ul ul, ul ol, ol ul, ol ol):not(:where([class~="not-prose"] *)) {
margin-top: 0.5em;
margin-bottom: 0;
}
.prose ol,
.prose ul {
list-style: none !important;
padding: 0;
margin-top: 0;
margin-bottom: 0;
}
.prose li p {
@apply !m-0 !leading-6;
.prose h1 {
margin-bottom: 0.25em;
}
.prose li p + p {
@apply !mt-2;
}
.prose li.ProseMirror-selectednode {
outline: 2px solid #8cf;
}
.prose li::after {
all: unset !important;
}
milkdown-list-item-block .list-item {
gap: 8px;
}
milkdown-list-item-block .label-wrapper {
height: 24px;
display: inline-flex;
justify-content: center;
align-items: center;
color: darkcyan;
}
/* End borrowed block. */

View file

@ -1,52 +1,2 @@
import { defaultValueCtx, Editor, rootCtx } from '@milkdown/core';
import { html } from 'atomico';
import { listItemBlockComponent, listItemBlockConfig, ListItemBlockConfig, listItemBlockView } from '@milkdown/components/list-item-block'
import { commonmark } from '@milkdown/preset-commonmark';
import { gfm } from '@milkdown/preset-gfm';
import { nord } from '@milkdown/theme-nord'
import { listener, listenerCtx } from '@milkdown/plugin-listener';
import '@milkdown/theme-nord/style.css'
function configureListItem(ctx: Ctx) {
ctx.set(listItemBlockConfig.key, {
renderLabel: (label: string, listType, checked?: boolean) => {
if (checked == null) {
if (listType === 'bullet') {
return html`<span class='label'>•</span>`
}
return html`<span class='label'>${label}</span>`
} else {
return html`<input class='label' type="checkbox" checked=${checked} />`
}
},
})
}
function createEditor(rootId, fieldId, content) {
Editor
.make()
.config(ctx => {
ctx.set(rootCtx, rootId)
ctx.set(defaultValueCtx, content)
const listener = ctx.get(listenerCtx);
listener.markdownUpdated((ctx, markdown, prevMarkdown) => {
if (markdown !== prevMarkdown) {
console.log(markdown);
console.log(fieldId);
document.getElementById(fieldId).value = markdown;
console.log("updated");
}
})
})
.config(configureListItem)
.use(commonmark)
.use(gfm)
.use(nord)
.use(listener)
.use(listItemBlockComponent)
.create();
}
window.createEditor = createEditor;
import { makeEditor } from './editor';
window.makeEditor = makeEditor;

2148
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -13,14 +13,9 @@
"css": "tailwindcss -i ./frontend/main.css -o ./static/style.css"
},
"dependencies": {
"@milkdown/components": "^7.3.6",
"@milkdown/core": "^7.3.6",
"@milkdown/plugin-listener": "^7.3.6",
"@milkdown/preset-commonmark": "^7.3.6",
"@milkdown/preset-gfm": "^7.3.6",
"@milkdown/theme-nord": "^7.3.6",
"@prosemirror-adapter/lit": "^0.2.6",
"@codemirror/lang-markdown": "^6.2.5",
"clsx": "^2.1.1",
"codemirror": "^6.0.1",
"tailwindcss": "^3.4.3"
}
}

View file

@ -104,6 +104,45 @@ pub async fn create_document_submit(
Ok(Redirect::to("/documents").into_response())
}
pub async fn view_document_page(
State(provider): State<Provider>,
auth_session: AuthSession<Provider>,
Path((id,)): Path<(Uuid,)>,
) -> Result<Response, (StatusCode, String)> {
let user = match auth_session.user {
Some(user) => user,
None => return Ok(Redirect::to("/login").into_response()),
};
let mut db = provider.db_pool.get().map_err(internal_error)?;
let document_allowed =
permissions::q::check_user_document(&mut db, &user.id, &id.to_string(), Permission::Write)
.map_err(internal_error)?;
if !document_allowed {
return Err((StatusCode::FORBIDDEN, "permission denied".to_owned()));
}
let document = match documents::q::by_id(&mut db, &id.to_string()).map_err(internal_error)? {
Some(doc) => doc,
None => return Err((StatusCode::NOT_FOUND, "document not found".to_owned())),
};
let projects =
permissions::q::accessible_projects(&mut db, &user.id).map_err(internal_error)?;
let rendered_document = document.render_html();
let values = context! {
user => user,
document => document,
projects => projects,
rendered_document => rendered_document,
};
provider.render_resp("documents/view_document.html", values)
}
pub async fn edit_document_page(
State(provider): State<Provider>,
auth_session: AuthSession<Provider>,
@ -176,5 +215,6 @@ pub async fn edit_document_submit(
)
.map_err(internal_error)?;
Ok(Redirect::to("/documents").into_response())
let view_url = format!("/documents/view/{}", document_id);
Ok(Redirect::to(&view_url).into_response())
}

View file

@ -48,7 +48,7 @@ pub async fn login_submit(
Form(creds): Form<Credentials>,
) -> Result<Response, (StatusCode, String)> {
if let Some(user) = auth_session.authenticate(creds).await.map_err(internal_error)? {
let _ = auth_session.login(&user).await.map_err(internal_error)?;
auth_session.login(&user).await.map_err(internal_error)?;
Ok(Redirect::to("/").into_response())
} else {
render_login_page(&provider, "", "", Some(LOGIN_ERROR_MSG))

View file

@ -1,4 +1,5 @@
use diesel::prelude::*;
use pulldown_cmark as markdown;
use serde::Serialize;
use uuid::Uuid;
@ -16,6 +17,21 @@ pub struct Document {
pub content: String,
}
impl Document {
pub fn render_html(&self) -> String {
let mut options = markdown::Options::empty();
options.insert(markdown::Options::ENABLE_TASKLISTS);
options.insert(markdown::Options::ENABLE_STRIKETHROUGH);
let parser = markdown::Parser::new_ext(&self.content, options);
let mut html_output = String::new();
markdown::html::push_html(&mut html_output, parser);
html_output
}
}
#[derive(Insertable)]
#[diesel(table_name = crate::schema::documents)]
pub struct NewDocument {

View file

@ -17,7 +17,7 @@ use crate::config::CommandLineOptions;
use crate::db;
use crate::handler::documents::{
create_document_page, create_document_submit, documents_page, edit_document_page,
edit_document_submit,
edit_document_submit, view_document_page,
};
use crate::handler::home::home_page;
use crate::handler::login::logout;
@ -66,6 +66,7 @@ pub async fn run() -> Result<()> {
.route("/documents", get(documents_page))
.route("/documents/new", get(create_document_page))
.route("/documents/new", post(create_document_submit))
.route("/documents/view/:id", get(view_document_page))
.route("/documents/edit/:id", get(edit_document_page))
.route("/documents/edit/:id", post(edit_document_submit))
.layer(trace_layer)

View file

View file

@ -1,196 +0,0 @@
/* node_modules/@milkdown/theme-nord/lib/style.css */
.ProseMirror {
position: relative;
word-wrap: break-word;
white-space: pre-wrap;
white-space: break-spaces;
font-variant-ligatures: none;
font-feature-settings: "liga" 0;
}
.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 [draggable][contenteditable=false] {
-webkit-user-select: text;
-moz-user-select: text;
user-select: text;
}
.ProseMirror-selectednode {
outline: 2px solid #8cf;
}
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;
}
img.ProseMirror-separator {
display: inline !important;
border: none !important;
margin: 0 !important;
}
.ProseMirror .tableWrapper {
overflow-x: auto;
}
.ProseMirror table {
border-collapse: collapse;
table-layout: fixed;
width: 100%;
overflow: hidden;
}
.ProseMirror td,
.ProseMirror th {
vertical-align: top;
box-sizing: border-box;
position: relative;
}
.ProseMirror .column-resize-handle {
position: absolute;
right: -2px;
top: 0;
bottom: 0;
width: 4px;
z-index: 20;
background-color: #adf;
pointer-events: none;
}
.ProseMirror.resize-cursor {
cursor: ew-resize;
cursor: col-resize;
}
.ProseMirror .selectedCell:after {
z-index: 2;
position: absolute;
content: "";
left: 0;
right: 0;
top: 0;
bottom: 0;
background: #c8c8ff66;
pointer-events: none;
}
.milkdown-theme-nord blockquote {
border-left-width: 4px;
--tw-border-opacity: 1;
border-color: rgb(94 129 172 / var(--tw-border-opacity));
padding-left: 1rem;
font-family:
ui-serif,
Georgia,
Cambria,
Times New Roman,
Times,
serif;
font-style: normal;
}
.milkdown-theme-nord code {
font-family:
ui-monospace,
SFMono-Regular,
Menlo,
Monaco,
Consolas,
Liberation Mono,
Courier New,
monospace;
font-weight: 400;
--tw-text-opacity: 1;
color: rgb(94 129 172 / var(--tw-text-opacity));
}
.milkdown-theme-nord pre code {
color: inherit;
}
.milkdown-theme-nord img {
margin-top: 0 !important;
margin-bottom: 0 !important;
display: inline-block;
max-width: 100%;
}
.milkdown-theme-nord.prose :where(blockquote):not(:where([class~=not-prose] *)) {
font-weight: 400;
}
.milkdown-theme-nord.prose :where(ol > li):not(:where([class~=not-prose] *))::marker,
.milkdown-theme-nord.prose :where(ul > li):not(:where([class~=not-prose] *))::marker {
--tw-text-opacity: 1;
color: rgb(94 129 172 / var(--tw-text-opacity));
}
.milkdown-theme-nord.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose] *)):before,
.milkdown-theme-nord.prose :where(blockquote p:first-of-type):not(:where([class~=not-prose] *)):after {
content: "";
}
.milkdown-theme-nord.prose :where(code):not(:where([class~=not-prose] *)):before,
.milkdown-theme-nord.prose :where(code):not(:where([class~=not-prose] *)):after {
content: "";
}
.milkdown-theme-nord.prose .tableWrapper {
position: relative;
margin-bottom: .5rem;
overflow-x: auto;
}
.milkdown-theme-nord.prose table {
margin: 1rem !important;
overflow: visible !important;
font-size: .875rem;
line-height: 1.25rem;
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / .1), 0 2px 4px -2px rgb(0 0 0 / .1);
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
box-shadow:
var(--tw-ring-offset-shadow, 0 0 #0000),
var(--tw-ring-shadow, 0 0 #0000),
var(--tw-shadow);
}
@media (min-width: 640px) {
.milkdown-theme-nord.prose table {
border-radius: .5rem;
}
}
.milkdown-theme-nord.prose td,
.milkdown-theme-nord.prose th {
padding: .75rem 1.5rem !important;
}
.milkdown-theme-nord.prose tr {
border-bottom-width: 1px;
--tw-border-opacity: 1;
border-color: rgb(229 231 235 / var(--tw-border-opacity));
}
:is(.dark .milkdown-theme-nord.prose tr) {
--tw-border-opacity: 1;
border-color: rgb(75 85 99 / var(--tw-border-opacity));
}
.milkdown-theme-nord.prose :where(td, th) p {
margin: 0 !important;
}
.milkdown-theme-nord.prose :where(td, th):nth-child(odd) {
--tw-bg-opacity: 1;
background-color: rgb(249 250 251 / var(--tw-bg-opacity));
}
:is(.dark .milkdown-theme-nord.prose :where(td, th):nth-child(odd)) {
--tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity));
}
.milkdown-theme-nord.prose.ProseMirror .selectedCell:after {
background-color: #88c0d04d;
}
.milkdown-theme-nord.prose br[data-is-inline=true],
.milkdown-theme-nord.prose br[data-is-inline=true]:after {
content: " ";
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -8,27 +8,21 @@
{% include "components/sidebar.html" %}
<main class="pl-72 bg-gray-50 h-full">
<form action="/documents/edit/{{ document.id }}" method="POST">
<div class="navbar bg-accent text-accent-content">
<div class="navbar-start gap-2">
<a class="btn" href="/documents">
{{ "arrow-left"|heroicon("w-6 h-6")|safe }} Back
</a>
<button class="btn" onClick="saveDocument()">Save</button>
</div>
<form action="/documents/edit/{{ document.id }}" method="POST" class="h-full flex flex-col">
<div class="py-1 px-1 gap-x-1 flex flex-row justify-center align-middle bg-accent text-accent-content">
<input type="text" id="title" name="title" class="grow font-bold" value="{{ document.title }}" />
<a class="btn btn-sm my-auto" href="/documents">
{{ "arrow-left"|heroicon("w-6 h-6")|safe }} Exit
</a>
<button class="btn btn-sm my-auto">Save</button>
</div>
<div class="px-8 py-8 flex flex-col gap-y-4">
<label class="input input-bordered flex items-center gap-2">
Title
<input type="text" id="title" name="title" class="grow" value="{{ document.title }}" />
</label>
<div id="editor-{{document.id}}" class="prose bg-white"></div>
<textarea id="content-{{ document.id }}" name="content" class="hidden">
</textarea>
<div class="flex flex-col h-full">
<div id="editor-{{ document.id }}" name="editor" class="w-full h-full">
<noscript>
<textarea id="content-{{ document.id }}" name="content" class="w-full h-full">{{ document.content }}</textarea>
</noscript>
</div>
</div>
</form>
</main>
@ -36,13 +30,8 @@
<script type="text/javascript" src="/static/main.js"></script>
<script type="text/javascript">
console.log("hi");
window.createEditor("#editor-{{document.id}}", "content-{{document.id}}", {{document.content | tojson }});
console.log({{document.content | tojson}});
console.log("hi2");
function saveDocument() {
console.log("saving");
}
window.makeEditor("#editor-{{document.id}}", {{ document.content | tojson }});
</script>
</body>
</html>

View file

@ -28,8 +28,8 @@
<div class="card-body">
<h2 class="card-title">{{ document.title }}</h2>
<div class="card-actions justify-end">
<a href="/documents/edit/{{ document.id }}" class="btn btn-primary">Open</a>
<a href="/documents/view/{{ document.id }}" class="btn btn-primary">View</a>
<a href="/documents/edit/{{ document.id }}" class="btn btn-primary">Edit</a>
</div>
</div>
</div>

View file

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en" class="h-full bg-white">
{% include "head.html" %}
<body class="h-full">
<div class="h-full">
{% set current_page = "documents" %}
{% include "components/sidebar.html" %}
<main class="pl-72 bg-gray-50 h-full">
<form action="" method="POST" class="h-full flex flex-col">
<div class="py-1 px-1 gap-x-1 flex flex-row justify-center align-middle bg-accent text-accent-content">
<h1 class="grow text-2xl font-bold">{{ document.title }}</h1>
<a class="btn btn-sm my-auto" href="/documents">
{{ "arrow-left"|heroicon("w-6 h-6")|safe }} Exit
</a>
<a class="btn btn-sm my-auto" href="/documents/edit/{{ document.id }}">Edit</a>
</div>
<div class="py-8 px-4 h-full prose prose-md">
{{ rendered_document|safe }}
</div>
</form>
</main>
<script type="text/javascript" src="/static/main.js"></script>
<script type="text/javascript">
window.makeEditor("#editor-{{document.id}}", {{ document.content | tojson }});
</script>
</body>
</html>

View file

@ -4,6 +4,5 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pique</title>
<link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/main.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⛰️</text></svg>"/>
</head>