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
of `$?` will be replaced with the correct argument number.
foo $? bar $? baz $?
foo ARG1 bar ARG2 baz $?
foo ARG1 bar ARG2 baz ARG3
foo $? bar $? baz $?
foo ARG1 bar ARG2 baz $?
foo ARG1 bar ARG2 baz ARG3
*/
func (qb *QueryBuilder) Add(sql string, args ...interface{}) {
numPlaceholders := strings.Count(sql, "$?")

View File

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

View File

@ -702,11 +702,11 @@ func BuildProjectCSS(color string) string {
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()
return Url("/assets/editorpreviews.js", nil)
return Url("/assets/markdown_worker.js", nil)
}
var RegexS3Asset *regexp.Regexp

View File

@ -1,11 +1,9 @@
{{ template "base.html" . }}
{{ define "extrahead" }}
<script src="{{ static "go_wasm_exec.js" }}"></script>
{{ template "markdown_previews.html" . }}
<script src="{{ static "js/base64.js" }}"></script>
<script>
const previewWorker = new Worker('/assets/editorpreviews.js');
</script>
<style>
#editor {
@ -73,15 +71,6 @@
<input name="editreason" maxlength="255" type="text" id="editreason" />
</span>
{{ 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 }}
<h4 class="mt3">The post you're replying to:</h4>
@ -89,27 +78,6 @@
{{ template "forum_post_standalone.html" . }}
</div>
{{ 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>
<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>
@ -127,74 +95,28 @@
const textField = document.querySelector('#editor');
const preview = document.querySelector('#preview');
const storagePrefix = 'post-contents';
// 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());
// Save content on change, clear on submit
const clearFuncs = [];
if (titleField) {
titleField.addEventListener('input', () => updateContentCache());
const { clear: clearTitle } = autosaveContent({
inputEl: titleField,
storageKey: `post-title/${window.location.host}${window.location.pathname}`,
});
clearFuncs.push(clearTitle);
}
form.addEventListener('submit', e => {
window.localStorage.removeItem(storageKey);
const { clear: clearContent } = autosaveContent({
inputEl: textField,
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

View File

@ -54,7 +54,7 @@
</div>
{{ end }}
{{ if .CanEdit }}
<div class="flex-grow-1"></div>
<div class="flex-grow-1 dn db-ns"></div>
<div class="root-item flex-shrink-0">
<a href="{{ .EditUrl }}">Edit Project</a>
</div>
@ -89,8 +89,8 @@
{{ end }}
</div>
<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 ml2" href="https://discord.gg/hxWxDee" target="_blank">{{ svg "discord" }}</a>
<a class="svgicon svgicon-nofix" href="https://twitter.com/handmade_net/" target="_blank">{{ svg "twitter" }}</a>
<a class="svgicon svgicon-nofix ml2" href="https://discord.gg/hxWxDee" target="_blank">{{ svg "discord" }}</a>
</div>
</div>
</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.
*/
let ready = false;
let inputData = null;
let wasmLoaded = false;
let jobs = {};
onmessage = ({ data }) => {
inputData = data;
const { elementID, markdown } = data;
jobs[elementID] = markdown;
setTimeout(doPreview, 0);
}
@ -17,17 +18,21 @@ const go = new Go();
WebAssembly.instantiateStreaming(fetch('/public/parsing.wasm'), go.importObject)
.then(result => {
go.run(result.instance); // don't await this; we want it to be continuously running
ready = true;
wasmLoaded = true;
setTimeout(doPreview, 0);
});
const doPreview = () => {
if (!ready || inputData === null) {
if (!wasmLoaded) {
return;
}
const result = parseMarkdown(inputData);
inputData = null;
postMessage(result);
for (const [elementID, markdown] of Object.entries(jobs)) {
const html = parseMarkdown(markdown);
postMessage({
elementID: elementID,
html: html,
});
}
jobs = {};
}

View File

@ -1,13 +1,22 @@
{{ template "base.html" . }}
{{ define "extrahead" }}
{{ template "markdown_previews.html" . }}
<script src="{{ static "js/tabs.js" }}"></script>
<script src="{{ static "js/image_selector.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 }}
{{ define "content" }}
<div class="content-block">
<div class="ph3 ph0-ns">
{{ if .Editing }}
<h1>Edit {{ .ProjectSettings.Name }}</h1>
{{ else }}
@ -28,20 +37,12 @@
<div>
<select name="lifecycle">
<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="dead" {{ if eq .ProjectSettings.Lifecycle "dead" }}selected{{ end }}>Abandoned</option>
</select>
</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="pt-input-ns">Owners:</div>
<div>
@ -72,12 +73,6 @@
<div class="edit-form-row">
<div class="pt-input-ns">Admin settings</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>Official:</div>
<div>
@ -85,12 +80,27 @@
<label for="official">Official HMN project</label>
</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>Featured:</div>
<div>
<input id="featured" type="checkbox" name="featured" {{ if .ProjectSettings.Featured }}checked{{ end }} />
<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>
{{ end }}
@ -118,10 +128,11 @@
<div class="edit-form-row">
<div class="pt-input-ns">Full description:</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 -}}
</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 class="edit-form-row">
@ -164,7 +175,7 @@
<script>
let csrf = JSON.parse({{ csrftokenjs .Session }});
let projectForm = document.querySelector("#project_form")
let projectForm = document.querySelector("#project_form");
projectForm.addEventListener("invalid", function(ev) {
switchToTabOfElement(document.body, ev.target);
@ -174,6 +185,25 @@
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 //
//////////////////////

View File

@ -432,7 +432,7 @@ func ProjectNewSubmit(c *RequestContext) ResponseData {
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 {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
@ -492,7 +492,7 @@ func ProjectEditSubmit(c *RequestContext) ResponseData {
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 {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
@ -609,7 +609,7 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult {
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
if payload.LightLogo.Exists {
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)
}
_, err := conn.Exec(ctx,
var qb db.QueryBuilder
qb.Add(
`
UPDATE handmade_project SET
name = $2,
blurb = $3,
description = $4,
descparsed = $5,
lifecycle = $6,
hidden = $7
WHERE
id = $1
name = $?,
blurb = $?,
description = $?,
descparsed = $?,
lifecycle = $?,
`,
payload.ProjectID,
payload.Name,
payload.Blurb,
payload.Description,
payload.ParsedDescription,
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 {
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.RegexProjectCSS, ProjectCSS)
anyProject.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData {
anyProject.GET(hmnurl.RegexMarkdownWorkerJS, func(c *RequestContext) 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")
return res
})