Add live markdown preview to the description editor

This commit is contained in:
Ben Visness 2021-12-06 23:20:12 -06:00
parent cf46e16df5
commit f5ed6ec896
10 changed files with 221 additions and 156 deletions

View File

@ -14,9 +14,9 @@ type QueryBuilder struct {
Adds the given SQL and arguments to the query. Any occurrences Adds the given SQL and arguments to the query. Any occurrences
of `$?` will be replaced with the correct argument number. of `$?` will be replaced with the correct argument number.
foo $? bar $? baz $? foo $? bar $? baz $?
foo ARG1 bar ARG2 baz $? foo ARG1 bar ARG2 baz $?
foo ARG1 bar ARG2 baz ARG3 foo ARG1 bar ARG2 baz ARG3
*/ */
func (qb *QueryBuilder) Add(sql string, args ...interface{}) { func (qb *QueryBuilder) Add(sql string, args ...interface{}) {
numPlaceholders := strings.Count(sql, "$?") numPlaceholders := strings.Count(sql, "$?")

View File

@ -298,12 +298,12 @@ func TestProjectCSS(t *testing.T) {
AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil) AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil)
} }
func TestEditorPreviewsJS(t *testing.T) { func TestMarkdownWorkerJS(t *testing.T) {
AssertRegexMatch(t, BuildEditorPreviewsJS(), RegexEditorPreviewsJS, nil) AssertRegexMatch(t, BuildMarkdownWorkerJS(), RegexMarkdownWorkerJS, nil)
} }
func TestAPICheckUsername(t *testing.T) { func TestAPICheckUsername(t *testing.T) {
AssertRegexmatch(t, BuildAPICheckUsername(), RegexAPICheckUsername, nil) AssertRegexMatch(t, BuildAPICheckUsername(), RegexAPICheckUsername, nil)
} }
func TestPublic(t *testing.T) { func TestPublic(t *testing.T) {

View File

@ -702,11 +702,11 @@ func BuildProjectCSS(color string) string {
return Url("/assets/project.css", []Q{{"color", color}}) return Url("/assets/project.css", []Q{{"color", color}})
} }
var RegexEditorPreviewsJS = regexp.MustCompile("^/assets/editorpreviews.js$") var RegexMarkdownWorkerJS = regexp.MustCompile("^/assets/markdown_worker.js$")
func BuildEditorPreviewsJS() string { func BuildMarkdownWorkerJS() string {
defer CatchPanic() defer CatchPanic()
return Url("/assets/editorpreviews.js", nil) return Url("/assets/markdown_worker.js", nil)
} }
var RegexS3Asset *regexp.Regexp var RegexS3Asset *regexp.Regexp

View File

@ -1,11 +1,9 @@
{{ template "base.html" . }} {{ template "base.html" . }}
{{ define "extrahead" }} {{ define "extrahead" }}
<script src="{{ static "go_wasm_exec.js" }}"></script> {{ template "markdown_previews.html" . }}
<script src="{{ static "js/base64.js" }}"></script> <script src="{{ static "js/base64.js" }}"></script>
<script>
const previewWorker = new Worker('/assets/editorpreviews.js');
</script>
<style> <style>
#editor { #editor {
@ -74,42 +72,12 @@
</span> </span>
{{ end }} {{ end }}
{{/* TODO: Sticky threads
{% if user.is_staff and post and post.depth == 0 %}
<div class="checkbox sticky">
<input type="checkbox" name="sticky" id="sticky" {% if thread.sticky %}checked{% endif%} />
<label for="sticky">Sticky thread</label>
</div>
{% endif %}
*/}}
{{ with .PostReplyingTo }} {{ with .PostReplyingTo }}
<h4 class="mt3">The post you're replying to:</h4> <h4 class="mt3">The post you're replying to:</h4>
<div class="mh-6 overflow-y-auto"> <div class="mh-6 overflow-y-auto">
{{ template "forum_post_standalone.html" . }} {{ template "forum_post_standalone.html" . }}
</div> </div>
{{ end }} {{ end }}
{{/*
{% if context_newer %}
<h4>Replies since then:</h4>
<div class="recent-posts">
{% for post in posts_newer %}
{% include "forum_thread_single_post.html" %}
{% endfor %}
</div>
{% endif %}
{% if context_older %}
<h4>Replies before then:</h4>
<div class="recent-posts">
{% for post in posts_older %}
{% include "forum_thread_single_post.html" %}
{% endfor %}
</div>
{% endif %}
*/}}
</form> </form>
<div id="preview-container" class="post post-preview mathjax flex-fair-ns overflow-auto mv3 mv0-ns ml3-ns pa3 br3 bg--dim"> <div id="preview-container" class="post post-preview mathjax flex-fair-ns overflow-auto mv3 mv0-ns ml3-ns pa3 br3 bg--dim">
<div id="preview" class="post-content"></div> <div id="preview" class="post-content"></div>
@ -127,74 +95,28 @@
const textField = document.querySelector('#editor'); const textField = document.querySelector('#editor');
const preview = document.querySelector('#preview'); const preview = document.querySelector('#preview');
const storagePrefix = 'post-contents'; // Save content on change, clear on submit
const clearFuncs = [];
// Delete old irrelevant local post contents
const aWeekAgo = new Date().getTime() - (7 * 24 * 60 * 60 * 1000);
for (const key in window.localStorage) {
if (!window.localStorage.hasOwnProperty(key)) {
continue;
}
if (key.startsWith(storagePrefix)) {
try {
const { when } = JSON.parse(window.localStorage.getItem(key));
if (when <= aWeekAgo) {
window.localStorage.removeItem(key);
}
} catch (e) {
console.error(e);
}
}
}
// Load any stored content from localStorage
const storageKey = `${storagePrefix}/${window.location.host}${window.location.pathname}`;
const storedContents = window.localStorage.getItem(storageKey);
if (storedContents && !textField.value) {
try {
const { title, contents } = JSON.parse(storedContents);
if (titleField) {
titleField.value = title;
}
textField.value = contents;
} catch (e) {
console.error(e);
}
}
function updatePreview(previewHtml) {
preview.innerHTML = previewHtml;
MathJax.typeset();
}
previewWorker.onmessage = ({ data }) => {
updatePreview(data);
};
function doMarkdown() {
const md = textField.value;
previewWorker.postMessage(md);
updateContentCache();
}
function updateContentCache() {
window.localStorage.setItem(storageKey, JSON.stringify({
when: new Date().getTime(),
title: titleField ? titleField.value : '',
contents: textField.value,
}));
}
doMarkdown();
textField.addEventListener('input', () => doMarkdown());
if (titleField) { if (titleField) {
titleField.addEventListener('input', () => updateContentCache()); const { clear: clearTitle } = autosaveContent({
inputEl: titleField,
storageKey: `post-title/${window.location.host}${window.location.pathname}`,
});
clearFuncs.push(clearTitle);
} }
const { clear: clearContent } = autosaveContent({
form.addEventListener('submit', e => { inputEl: textField,
window.localStorage.removeItem(storageKey); storageKey: `post-content/${window.location.host}${window.location.pathname}`,
}); });
clearFuncs.push(clearContent);
form.addEventListener('submit', e => {
for (const clear of clearFuncs) {
clear();
}
});
// Do live Markdown previews
initLiveMarkdown({ inputEl: textField, previewEl: preview });
/* /*
/ Asset upload / Asset upload

View File

@ -54,7 +54,7 @@
</div> </div>
{{ end }} {{ end }}
{{ if .CanEdit }} {{ if .CanEdit }}
<div class="flex-grow-1"></div> <div class="flex-grow-1 dn db-ns"></div>
<div class="root-item flex-shrink-0"> <div class="root-item flex-shrink-0">
<a href="{{ .EditUrl }}">Edit Project</a> <a href="{{ .EditUrl }}">Edit Project</a>
</div> </div>
@ -89,8 +89,8 @@
{{ end }} {{ end }}
</div> </div>
<div class="dn flex-ns items-center f3"> <div class="dn flex-ns items-center f3">
<a class="svgicon" href="https://twitter.com/handmade_net/" target="_blank">{{ svg "twitter" }}</a> <a class="svgicon svgicon-nofix" href="https://twitter.com/handmade_net/" target="_blank">{{ svg "twitter" }}</a>
<a class="svgicon ml2" href="https://discord.gg/hxWxDee" target="_blank">{{ svg "discord" }}</a> <a class="svgicon svgicon-nofix ml2" href="https://discord.gg/hxWxDee" target="_blank">{{ svg "discord" }}</a>
</div> </div>
</div> </div>
</header> </header>

View File

@ -0,0 +1,106 @@
<script src="{{ static "go_wasm_exec.js" }}"></script>
<script>
const previewWorker = new Worker('/assets/markdown_worker.js');
/*
Automatically save and restore content from a text field on change.
Return type:
{
// Call this function when you submit the form or otherwise want
// to delete the work-in-progress user content.
clearStorage: () => {},
}
*/
function autosaveContent({
// HTML input or textarea
inputEl,
// Unique string identifying this field across the site.
storageKey,
}) {
const storagePrefix = 'saved-content';
// Delete old irrelevant local contents
const aWeekAgo = new Date().getTime() - (7 * 24 * 60 * 60 * 1000);
for (const key in window.localStorage) {
if (!window.localStorage.hasOwnProperty(key)) {
continue;
}
if (key.startsWith(storagePrefix)) {
try {
const { when } = JSON.parse(window.localStorage.getItem(key));
if (when <= aWeekAgo) {
window.localStorage.removeItem(key);
}
} catch (e) {
console.error(e);
}
}
}
// Load any stored content from localStorage
const storageKeyFull = `${storagePrefix}/${storageKey}`;
const storedContents = window.localStorage.getItem(storageKeyFull);
if (storedContents && !inputEl.value) {
try {
const { contents } = JSON.parse(storedContents);
inputEl.value = contents;
} catch (e) {
console.error(e);
}
}
function updateContentCache() {
window.localStorage.setItem(storageKeyFull, JSON.stringify({
when: new Date().getTime(),
contents: inputEl.value,
}));
}
inputEl.addEventListener('input', () => updateContentCache());
return {
clear() {
window.localStorage.removeItem(storageKeyFull);
},
}
}
const markdownIds = [];
/*
Initialize live Markdown rendering.
*/
function initLiveMarkdown({
// HTML input or textarea
inputEl,
// HTML element in which to render markdown
previewEl,
}) {
if (markdownIds.includes(inputEl.id)) {
console.warn(`Multiple elements with ID "${inputEl.id}" are being used for Markdown. Results will be very confusing!`);
}
markdownIds.push(inputEl.id);
previewWorker.onmessage = ({ data }) => {
const { elementID, html } = data;
if (elementID === inputEl.id) {
previewEl.innerHTML = html;
MathJax.typeset();
}
};
function doMarkdown() {
previewWorker.postMessage({
elementID: inputEl.id,
markdown: inputEl.value,
});
}
doMarkdown();
inputEl.addEventListener('input', () => doMarkdown());
}
</script>

View File

@ -5,11 +5,12 @@ NOTE(ben): The structure here is a little funny but allows for some debouncing.
that got queued up can run all at once, then it can process the latest one. that got queued up can run all at once, then it can process the latest one.
*/ */
let ready = false; let wasmLoaded = false;
let inputData = null; let jobs = {};
onmessage = ({ data }) => { onmessage = ({ data }) => {
inputData = data; const { elementID, markdown } = data;
jobs[elementID] = markdown;
setTimeout(doPreview, 0); setTimeout(doPreview, 0);
} }
@ -17,17 +18,21 @@ const go = new Go();
WebAssembly.instantiateStreaming(fetch('/public/parsing.wasm'), go.importObject) WebAssembly.instantiateStreaming(fetch('/public/parsing.wasm'), go.importObject)
.then(result => { .then(result => {
go.run(result.instance); // don't await this; we want it to be continuously running go.run(result.instance); // don't await this; we want it to be continuously running
ready = true; wasmLoaded = true;
setTimeout(doPreview, 0); setTimeout(doPreview, 0);
}); });
const doPreview = () => { const doPreview = () => {
if (!ready || inputData === null) { if (!wasmLoaded) {
return; return;
} }
const result = parseMarkdown(inputData); for (const [elementID, markdown] of Object.entries(jobs)) {
inputData = null; const html = parseMarkdown(markdown);
postMessage({
postMessage(result); elementID: elementID,
html: html,
});
}
jobs = {};
} }

View File

@ -1,13 +1,22 @@
{{ template "base.html" . }} {{ template "base.html" . }}
{{ define "extrahead" }} {{ define "extrahead" }}
{{ template "markdown_previews.html" . }}
<script src="{{ static "js/tabs.js" }}"></script> <script src="{{ static "js/tabs.js" }}"></script>
<script src="{{ static "js/image_selector.js" }}"></script> <script src="{{ static "js/image_selector.js" }}"></script>
<script src="{{ static "js/templates.js" }}"></script> <script src="{{ static "js/templates.js" }}"></script>
<style>
#desc-preview:empty::after {
content: 'A preview of your description will appear here.';
color: var(--dimmer-color);
font-style: italic;
}
</style>
{{ end }} {{ end }}
{{ define "content" }} {{ define "content" }}
<div class="content-block"> <div class="ph3 ph0-ns">
{{ if .Editing }} {{ if .Editing }}
<h1>Edit {{ .ProjectSettings.Name }}</h1> <h1>Edit {{ .ProjectSettings.Name }}</h1>
{{ else }} {{ else }}
@ -28,20 +37,12 @@
<div> <div>
<select name="lifecycle"> <select name="lifecycle">
<option value="active" {{ if eq .ProjectSettings.Lifecycle "active" }}selected{{ end }}>Active</option> <option value="active" {{ if eq .ProjectSettings.Lifecycle "active" }}selected{{ end }}>Active</option>
<option value="hiatus" {{ if eq .ProjectSettings.Lifecycle "hiatus" }}selected{{ end }}>Hiatus</option> <option value="hiatus" {{ if eq .ProjectSettings.Lifecycle "hiatus" }}selected{{ end }}>On Hiatus</option>
<option value="done" {{ if eq .ProjectSettings.Lifecycle "done" }}selected{{ end }}>Completed</option> <option value="done" {{ if eq .ProjectSettings.Lifecycle "done" }}selected{{ end }}>Completed</option>
<option value="dead" {{ if eq .ProjectSettings.Lifecycle "dead" }}selected{{ end }}>Abandoned</option> <option value="dead" {{ if eq .ProjectSettings.Lifecycle "dead" }}selected{{ end }}>Abandoned</option>
</select> </select>
</div> </div>
</div> </div>
<div class="edit-form-row">
{{/* TODO(asaf): Should this be admin only??*/}}
<div>Hidden:</div>
<div>
<input id="hidden" type="checkbox" name="hidden" {{ if .ProjectSettings.Hidden }}checked{{ end }} />
<label for="hidden">Hide</label>
</div>
</div>
<div class="edit-form-row"> <div class="edit-form-row">
<div class="pt-input-ns">Owners:</div> <div class="pt-input-ns">Owners:</div>
<div> <div>
@ -72,12 +73,6 @@
<div class="edit-form-row"> <div class="edit-form-row">
<div class="pt-input-ns">Admin settings</div> <div class="pt-input-ns">Admin settings</div>
</div> </div>
<div class="edit-form-row">
<div class="pt-input-ns">Slug:</div>
<div>
<input type="text" name="slug" maxlength="255" class="textbox" value="{{ .ProjectSettings.Slug }}">
</div>
</div>
<div class="edit-form-row"> <div class="edit-form-row">
<div>Official:</div> <div>Official:</div>
<div> <div>
@ -85,12 +80,27 @@
<label for="official">Official HMN project</label> <label for="official">Official HMN project</label>
</div> </div>
</div> </div>
<div class="edit-form-row">
<div>Hidden:</div>
<div>
<input id="hidden" type="checkbox" name="hidden" {{ if .ProjectSettings.Hidden }}checked{{ end }} />
<label for="hidden">Hide</label>
</div>
</div>
<div class="edit-form-row">
<div class="pt-input-ns">Slug:</div>
<div>
<input type="text" name="slug" maxlength="255" class="textbox" value="{{ .ProjectSettings.Slug }}">
<div class="c--dim f7">Has no effect for personal projects. Personal projects have a slug derived from the title.</div>
<div class="c--dim f7">If you change this, make sure to change DNS too!</div>
</div>
</div>
<div class="edit-form-row"> <div class="edit-form-row">
<div>Featured:</div> <div>Featured:</div>
<div> <div>
<input id="featured" type="checkbox" name="featured" {{ if .ProjectSettings.Featured }}checked{{ end }} /> <input id="featured" type="checkbox" name="featured" {{ if .ProjectSettings.Featured }}checked{{ end }} />
<label for="featured">Featured</label> <label for="featured">Featured</label>
<div class="c--dim f7">Bump to the top of the project index and show in the carousel.</div> <div class="c--dim f7">Bump to the top of the project index and show in the carousel. Has no effect for personal projects.</div>
</div> </div>
</div> </div>
{{ end }} {{ end }}
@ -118,10 +128,11 @@
<div class="edit-form-row"> <div class="edit-form-row">
<div class="pt-input-ns">Full description:</div> <div class="pt-input-ns">Full description:</div>
<div> <div>
<textarea class="w-100 h6 minh-6 mono lh-copy" name="description"> <textarea id="description" class="w-100 h5 minh-5 mono lh-copy" name="description">
{{- .ProjectSettings.Description -}} {{- .ProjectSettings.Description -}}
</textarea> </textarea>
{{/* TODO(asaf): Replace with the full editor */}} <div class="b mt3 mb2">Preview:</div>
<div id="desc-preview" class="w-100"></div>
</div> </div>
</div> </div>
<div class="edit-form-row"> <div class="edit-form-row">
@ -164,7 +175,7 @@
<script> <script>
let csrf = JSON.parse({{ csrftokenjs .Session }}); let csrf = JSON.parse({{ csrftokenjs .Session }});
let projectForm = document.querySelector("#project_form") let projectForm = document.querySelector("#project_form");
projectForm.addEventListener("invalid", function(ev) { projectForm.addEventListener("invalid", function(ev) {
switchToTabOfElement(document.body, ev.target); switchToTabOfElement(document.body, ev.target);
@ -174,6 +185,25 @@
switchTab(document.body, tabName); switchTab(document.body, tabName);
} }
////////////////////////////
// Description management //
////////////////////////////
{{ if .Editing }}
const projectName = "new-project";
{{ else }}
const projectName = "{{ .Project.Name }}";
{{ end }}
const description = document.querySelector('#description');
const descPreview = document.querySelector('#desc-preview');
const { clear: clearDescription } = autosaveContent({
inputEl: description,
storageKey: `project-description/${projectName}`,
});
projectForm.addEventListener('submit', () => clearDescription());
initLiveMarkdown({ inputEl: description, previewEl: descPreview });
////////////////////// //////////////////////
// Owner management // // Owner management //
////////////////////// //////////////////////

View File

@ -432,7 +432,7 @@ func ProjectNewSubmit(c *RequestContext) ResponseData {
formResult.Payload.ProjectID = projectId formResult.Payload.ProjectID = projectId
err = UpdateProject(c.Context(), tx, c.CurrentUser, &formResult.Payload) err = updateProject(c.Context(), tx, c.CurrentUser, &formResult.Payload)
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err) return c.ErrorResponse(http.StatusInternalServerError, err)
} }
@ -492,7 +492,7 @@ func ProjectEditSubmit(c *RequestContext) ResponseData {
formResult.Payload.ProjectID = c.CurrentProject.ID formResult.Payload.ProjectID = c.CurrentProject.ID
err = UpdateProject(c.Context(), tx, c.CurrentUser, &formResult.Payload) err = updateProject(c.Context(), tx, c.CurrentUser, &formResult.Payload)
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err) return c.ErrorResponse(http.StatusInternalServerError, err)
} }
@ -609,7 +609,7 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult {
return res return res
} }
func UpdateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, payload *ProjectPayload) error { func updateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, payload *ProjectPayload) error {
var lightLogoUUID *uuid.UUID var lightLogoUUID *uuid.UUID
if payload.LightLogo.Exists { if payload.LightLogo.Exists {
lightLogo := &payload.LightLogo lightLogo := &payload.LightLogo
@ -657,26 +657,28 @@ func UpdateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, pay
payload.OwnerUsernames = append(payload.OwnerUsernames, selfUsername) payload.OwnerUsernames = append(payload.OwnerUsernames, selfUsername)
} }
_, err := conn.Exec(ctx, var qb db.QueryBuilder
qb.Add(
` `
UPDATE handmade_project SET UPDATE handmade_project SET
name = $2, name = $?,
blurb = $3, blurb = $?,
description = $4, description = $?,
descparsed = $5, descparsed = $?,
lifecycle = $6, lifecycle = $?,
hidden = $7
WHERE
id = $1
`, `,
payload.ProjectID,
payload.Name, payload.Name,
payload.Blurb, payload.Blurb,
payload.Description, payload.Description,
payload.ParsedDescription, payload.ParsedDescription,
payload.Lifecycle, payload.Lifecycle,
payload.Hidden,
) )
if user.IsStaff {
qb.Add(`hidden = $?`, payload.Hidden)
}
qb.Add(`WHERE id = $?`, payload.ProjectID)
_, err := conn.Exec(ctx, qb.String(), qb.Args()...)
if err != nil { if err != nil {
return oops.New(err, "Failed to update project") return oops.New(err, "Failed to update project")
} }

View File

@ -350,9 +350,9 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
anyProject.GET(hmnurl.RegexCineraIndex, CineraIndex) anyProject.GET(hmnurl.RegexCineraIndex, CineraIndex)
anyProject.GET(hmnurl.RegexProjectCSS, ProjectCSS) anyProject.GET(hmnurl.RegexProjectCSS, ProjectCSS)
anyProject.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData { anyProject.GET(hmnurl.RegexMarkdownWorkerJS, func(c *RequestContext) ResponseData {
var res ResponseData var res ResponseData
res.MustWriteTemplate("editorpreviews.js", nil, c.Perf) res.MustWriteTemplate("markdown_worker.js", nil, c.Perf)
res.Header().Add("Content-Type", "application/javascript") res.Header().Add("Content-Type", "application/javascript")
return res return res
}) })