web/front/EditorPage.tsx
import Editor, { DiffEditor, useMonaco, loader } from '@monaco-editor/react'
import React, { useRef, useEffect } from 'react'
import Router, { useRouter } from 'next/router'
import lodash from 'lodash'
import pluralize from 'pluralize'
import ourbigbook from 'ourbigbook';
import ourbigbook_tex from 'ourbigbook/default.tex';
import web_api from 'ourbigbook/web_api';
import { preload_katex } from 'ourbigbook/nodejs_front';
import { ourbigbook_runtime } from 'ourbigbook/dist/ourbigbook_runtime.js';
import { OurbigbookEditor } from 'ourbigbook/editor.js';
import { convertOptions, docsUrl, forbidMultiheaderMessage, sureLeaveMessage, isProduction, read_include_web } from 'front/config';
import {
ArticleBy,
capitalize,
disableButton,
enableButton,
CancelIcon,
HelpIcon,
slugFromArray
} from 'front'
import ErrorList from 'front/ErrorList'
import { webApi } from 'front/api'
import routes from 'front/routes'
import { AppContext, useCtrlEnterSubmit } from 'front'
import { hasReachedMaxItemCount, modifyEditorInput } from 'front/js';
import { ArticleType } from 'front/types/ArticleType'
import { IssueType } from 'front/types/IssueType'
import Label from 'front/Label'
import { UserType } from 'front/types/UserType'
/** DbProvider that fetchs data via the OurBigBook Web REST API. */
class RestDbProvider extends web_api.DbProviderBase {
fetched_ids: Set<string>;
fetched_files: Set<string>;
constructor() {
super()
this.fetched_ids = new Set()
this.fetched_files = new Set()
}
async get_noscopes_base_fetch(ids, ignore_paths_set, context) {
const unfetched_ids = []
for (const id of ids) {
if (
// Small optimization, don't fetch IDs that don't start with @, that is the case for every web ID.
// And if there are two @, it means user is doing <@other-user/mytopic>, so we also don't need
// to try and fetch <@myself/@other-user/mytopic>
(id.match(new RegExp(ourbigbook.AT_MENTION_CHAR, 'g')) || []).length === 1 &&
!this.fetched_ids.has(id)
) {
this.fetched_ids.add(id)
unfetched_ids.push(id)
}
}
if (unfetched_ids.length) {
const { data: { rows }, status } = await webApi.editorGetNoscopesBaseFetch(unfetched_ids, Array.from(ignore_paths_set))
// @ts-ignore: Property 'rows_to_asts' does not exist on type 'RestDbProvider'.
return this.rows_to_asts(rows, context)
} else {
return []
}
}
async get_refs_to_fetch(types, to_ids, { reversed, ignore_paths_set, context }) {
return []
}
async fetch_header_tree_ids(starting_ids_to_asts) {
return []
}
async fetch_ancestors(toplevel_id) {
return []
}
build_header_tree(fetch_header_tree_ids_rows, { context }) {
return []
}
fetch_ancestors_build_tree(rows, context) {
return []
}
get_refs_to(type, to_id, reversed=false) {
return []
}
async fetch_files(paths, context) {
const unfetched_files = []
for (const path of paths) {
if (!this.fetched_files.has(path)) {
this.fetched_files.add(path)
unfetched_files.push(path)
}
}
if (unfetched_files.length) {
const { data: { files }, status } = await webApi.editorFetchFiles(unfetched_files)
for (const file of files) {
// This was likely not fixed for the editor case: https://github.com/ourbigbook/ourbigbook/issues/240
// But I'll just pretend it's fine for now until this gets digged up a few years later.
// @ts-ignore: Property 'add_file_row_to_cache' does not exist on type 'RestDbProvider'.
this.add_file_row_to_cache(file, context)
}
}
}
}
export interface EditorPageProps {
article: ArticleType & IssueType;
articleCountByLoggedInUser: number;
issueArticle?: ArticleType;
loggedInUser: UserType;
parentTitle?: string,
previousSiblingTitle?: string,
titleSource?: string;
titleSourceLine?: number;
}
function titleToId(loggedInUser, title) {
let ret = `${ourbigbook.AT_MENTION_CHAR}${loggedInUser.username}`
const topicId = ourbigbook.titleToId(title)
if (topicId !== ourbigbook.INDEX_BASENAME_NOEXT) {
ret += `/${topicId}`
}
return ret
}
function titleToPath(loggedInUser, title) {
return `${titleToId(loggedInUser, title)}.${ourbigbook.OURBIGBOOK_EXT}`
}
const idExistsCache = {}
async function cachedIdExists(idid) {
if (idid in idExistsCache) {
return idExistsCache[idid]
} else {
const ret = await webApi.editorIdExists(idid)
idExistsCache[idid] = ret
return ret
}
}
const parentTitleDisplay = 'Parent article'
const previousSiblingTitleDisplay = 'Previous sibling'
export default function EditorPageHoc({
isIssue=false,
isNew=false,
}={}) {
const editor = ({
article: initialArticle,
articleCountByLoggedInUser,
issueArticle,
loggedInUser,
parentTitle: initialParentTitle,
previousSiblingTitle: initialPreviousSiblingTitle,
titleSource,
}: EditorPageProps) => {
const router = useRouter();
const {
query: { slug },
} = router;
let bodySource;
let slugString
if (Array.isArray(slug)) {
slugString = slug.join('/')
} else {
slugString = slug
}
let initialFileState;
let initialFile
if (initialArticle && !(isNew && isIssue)) {
initialFile = isIssue ? initialArticle : initialArticle.file
bodySource = initialFile.bodySource
if (slugString && isNew && !isIssue) {
bodySource += `${ourbigbook.PARAGRAPH_SEP}Adapted from: \\x[${ourbigbook.AT_MENTION_CHAR}${slugString}].`
}
initialFileState = {
titleSource: initialFile.titleSource || titleSource,
}
} else {
bodySource = ""
initialFileState = {
titleSource: titleSource || '',
}
}
let isIndex
if (!isNew && !isIssue) {
isIndex = initialArticle.topicId === ''
} else {
isIndex = false
}
const itemType = isIssue ? 'discussion' : 'article'
const [isLoading, setLoading] = React.useState(false);
const [editorLoaded, setEditorLoading] = React.useState(false);
// TODO titleErrors can be undefined immediately after this call,
// if the server gives 500 after update. It is some kind of race condition
// and then it blows up at a later titleErrors.length. It is some kind of race
// condition that needs debugging at some point.
const [titleErrors, setTitleErrors] = React.useState([]);
const [parentErrors, setParentErrors] = React.useState([]);
const [hasConvertError, setHasConvertError] = React.useState(false);
const [file, setFile] = React.useState(initialFileState);
const [parentTitle, setParentTitle] = React.useState(initialParentTitle || 'Index');
const [previousSiblingTitle, setPreviousSiblingTitle] = React.useState(initialPreviousSiblingTitle || '');
const ourbigbookEditorElem = useRef(null);
const ourbigbookHeaderElem = useRef(null);
const ourbigbookParentIdContainerElem = useRef(null);
const saveButtonElem = useRef(null);
const maxReached = hasReachedMaxItemCount(loggedInUser, articleCountByLoggedInUser, pluralize(itemType))
let editor
const finalConvertOptions = lodash.merge({
db_provider: new RestDbProvider(),
forbid_multiheader: isIssue ? undefined : forbidMultiheaderMessage,
input_path: initialFile?.path || titleToPath(loggedInUser, 'asdf'),
katex_macros: preload_katex(ourbigbook_tex),
ourbigbook_json: {
openLinksOnNewTabs: true,
},
read_include: read_include_web(cachedIdExists),
ref_prefix: `${ourbigbook.AT_MENTION_CHAR}${loggedInUser.username}`,
}, convertOptions)
async function checkTitle(titleSource) {
let titleErrors = []
if (titleSource) {
if (!isIssue) {
let newTopicId = ourbigbook.titleToId(titleSource)
let showToUserNew
if (newTopicId === ourbigbook.INDEX_BASENAME_NOEXT) {
// Maybe there is a more factored out way of dealing with this edge case.
newTopicId = ''
showToUserNew = ourbigbook.INDEX_BASENAME_NOEXT
} else {
showToUserNew = newTopicId
}
if (isNew) {
// finalConvertOptions.input_path
const id = `${ourbigbook.AT_MENTION_CHAR}${loggedInUser.username}/${newTopicId}`
if (await cachedIdExists(id)) {
titleErrors.push(`Article ID already taken: "${id}" `)
}
} else if (!isIssue && initialArticle.topicId !== newTopicId) {
let showToUserOld
if (initialArticle?.topicId === '') {
showToUserOld = ourbigbook.INDEX_BASENAME_NOEXT
} else {
showToUserOld = initialArticle?.topicId
}
titleErrors.push(`Topic ID changed from "${showToUserOld}" to "${showToUserNew}", this is not currently allowed`)
}
}
} else {
titleErrors.push('Title cannot be empty')
}
setTitleErrors(titleErrors)
}
useEffect(() => {
// Initial check here, then check only on title update.
checkTitle(file.titleSource)
}, [])
useEffect(() => {
window.onbeforeunload = function(){
if (file.titleSource) {
return sureLeaveMessage
}
}
}, [file.titleSource])
useEffect(() => {
if (
// Can fail on maximum number of articles reached.
saveButtonElem.current
) {
if (hasConvertError || titleErrors.length || parentErrors.length) {
disableButton(saveButtonElem.current)
} else {
enableButton(saveButtonElem.current)
}
}
}, [
hasConvertError,
saveButtonElem.current,
titleErrors,
parentErrors
])
useEffect(() => {
if (
ourbigbookEditorElem &&
loggedInUser &&
!maxReached
) {
let editor
loader.init().then(monaco => {
finalConvertOptions.x_external_prefix = '../'.repeat(window.location.pathname.match(/\//g).length - 1)
editor = new OurbigbookEditor(
ourbigbookEditorElem.current,
bodySource,
monaco,
ourbigbook,
ourbigbook_runtime,
{
convertOptions: finalConvertOptions,
handleSubmit,
initialLine: initialArticle ? initialArticle.titleSourceLine : undefined,
modifyEditorInput: (oldInput) => modifyEditorInput(file.titleSource, oldInput),
postBuildCallback: (extra_returns) => {
setHasConvertError(extra_returns.errors.length > 0)
const first_header = extra_returns.context.header_tree.children[0]
if (isNew && first_header) {
const id = first_header.ast.id
// TODO
// Not working because finalConvertOptions.input_path setting in handleTitle
// not taking effect. This would be the better way to check for it.
//if (file.titleSource && cachedIdExists(id)) {
// setTitleErrors([`Article ID already taken: "${id}"`])
//}
}
},
production: isProduction,
scrollPreviewToSourceLineCallback: (
opts : {
line_number?: number;
line_number_orig?: number;
ourbigbook_editor?: any;
} = {}
) => {
const { ourbigbook_editor, line_number, line_number_orig } = opts
const editor = ourbigbook_editor.editor
const visibleRange = editor.getVisibleRanges()[0]
const firstVisibleLine = visibleRange.startLineNumber
if (firstVisibleLine === 1) {
ourbigbookHeaderElem.current.classList.remove('hide')
if (
// Fails for index page.
ourbigbookParentIdContainerElem.current !== null
) {
ourbigbookParentIdContainerElem.current.classList.remove('hide')
}
} else {
if (
// TODO this is to prevent infinite loop/glitching:
// - user left line 1, hide header
// - editor becomes larger, but text is not much larger than the small viewport, so line 1 now visible again, show header
// - loop
// What we would like is to find the correct number of lines without hardcoding this line count
// That hardcoded number is a number of lines that is taller than what gets hidden,
// which was about 5 lines high when this was hardcoded.
// Maybe something smarter can be done with editor.onDidLayoutChange.
editor.getModel().getLineCount() - (visibleRange.endLineNumber - visibleRange.startLineNumber) > 14
) {
ourbigbookHeaderElem.current.classList.add('hide')
ourbigbookParentIdContainerElem.current.classList.add('hide')
}
}
},
},
)
ourbigbookEditorElem.current.ourbigbookEditor = editor
// To ensure an initial conversion in case user has modified title before the editor had loaded.
// Otherwise title would only update if user edited title again.
setEditorLoading(true)
})
return () => {
// TODO cleanup here not working.
// Blows exception when changing page title because scroll callback calls for the new page.
// This also leads the redirected article page to be at a random scroll and not on top.
// Maybe try to extract a solution from:
// https://github.com/suren-atoyan/monaco-react/blob/9acaf635caf6d738173e53434984252baa8b06d9/src/Editor/Editor.js
// What happens: order is ArticlePage -> onDidScrollChange -> dispose
// but we need dispose to be the first thing.
//ourbigbookEditorRef.current.ourbigbookEditor.dispose()
if (editor) {
editor.dispose()
}
};
}
}, [loggedInUser?.username])
async function checkParent(loggedInUser, title, otherTitle, display) {
const parentErrors = []
if (title) {
const id = titleToId(loggedInUser, title)
if (!(await cachedIdExists(id))) {
parentErrors.push(`${display} ID "${id}" does not exist`)
}
} else if (!otherTitle) {
parentErrors.push(`${parentTitleDisplay} and ${previousSiblingTitleDisplay} can't both be empty`)
}
setParentErrors(parentErrors)
}
const handleParentTitle = async (e) => {
const title = e.target.value
setParentTitle(title)
await checkParent(loggedInUser, title, previousSiblingTitle, parentTitleDisplay)
}
const handlePreviousSiblingTitle = async (e) => {
const title = e.target.value
setPreviousSiblingTitle(title)
await checkParent(loggedInUser, title, parentTitle, previousSiblingTitleDisplay)
}
const handleTitle = async (e) => {
const titleSource = e.target.value
setFile(file => { return {
...file,
titleSource,
}})
checkTitle(titleSource)
// TODO this would be slighty better, but not taking effect, I simply can't understand why,
// there appear to be no copies of convertOptions under editor...
//if (titleSource) {
// finalConvertOptions.input_path = titleToPath(loggedInUser, titleSource)
//}
}
useEffect(() => {
if (
// Can fail on maximum number of articles reached.
ourbigbookEditorElem.current &&
// Can fail is user starts editing title quickly after page load before editor had time to load.
ourbigbookEditorElem.current.ourbigbookEditor
) {
ourbigbookEditorElem.current.ourbigbookEditor.setModifyEditorInput(
oldInput => modifyEditorInput(file.titleSource, oldInput))
}
}, [file, editorLoaded, ourbigbookEditorElem.current])
const handleSubmit = async (e) => {
if (e) {
e.preventDefault();
}
if (hasConvertError || titleErrors.length) {
// Although the button should be disabled from clicks,
// this could still be reached via the Ctrl shortcut.
return
}
setLoading(true);
let data, status;
file.bodySource = ourbigbookEditorElem.current.ourbigbookEditor.getValue()
if (isIssue) {
if (isNew) {
;({ data, status } = await webApi.issueCreate(slugString, file))
} else {
;({ data, status } = await webApi.issueEdit(slugString, router.query.number, file))
}
} else {
const opts: { path?: string, parentId?: string, previousSiblingId?: string } = {}
if (!isIndex) {
opts.parentId = titleToId(loggedInUser, parentTitle)
if (previousSiblingTitle) {
opts.previousSiblingId = titleToId(loggedInUser, previousSiblingTitle)
}
}
if (isNew) {
;({ data, status } = await webApi.articleCreate(file, opts));
} else {
const path = slugFromArray(ourbigbook.pathSplitext(initialFile.path)[0].split(ourbigbook.Macro.HEADER_SCOPE_SEPARATOR), { username: false })
if (path) {
opts.path = path
}
;({ data, status } = await webApi.articleCreateOrUpdate(file, opts))
}
}
setLoading(false);
if (status === 200) {
// This is a hack for the useEffect cleanup callback issue.
ourbigbookEditorElem.current.ourbigbookEditor.dispose()
let redirTarget
if (isIssue) {
redirTarget = routes.issue(slugString, data.issue.number)
} else {
if (isNew) {
redirTarget = routes.article(data.articles[0].slug)
} else {
redirTarget = routes.article(slugString)
}
}
Router.push(redirTarget, null, { scroll: true });
} else {
setTitleErrors(data.errors);
}
};
useCtrlEnterSubmit(handleSubmit)
const handleCancel = async (e) => {
if (!ourbigbookEditorElem.current.ourbigbookEditor.modified || confirm('Are you sure you want to abandon your changes?')) {
if (isNew) {
Router.push(`/`);
} else {
// This is a hack for the useEffect cleanup callback issue.
ourbigbookEditorElem.current.ourbigbookEditor.dispose()
Router.push(routes.article(initialArticle.slug));
}
}
}
const { setTitle } = React.useContext(AppContext)
React.useEffect(() => {
setTitle(isNew ? `New ${itemType}` : `Editing ${isIssue
? `discussion #${initialFile.number} "${initialFile.titleSource}" on ${issueArticle.titleSource} by ${issueArticle.author.displayName}`
: `"${initialFile.titleSource}" by ${initialArticle.author.displayName}`
}`)
}, [isNew, initialFile?.titleSource])
return (
<div className="editor-page content-not-ourbigbook">
{ maxReached
? <p>{maxReached}</p>
: <>
<div className="header" ref={ourbigbookHeaderElem}>
<h1>
{isNew
? `New ${itemType}`
: <>
Editing
{' '}
{isIssue
? <a href={isIssue ? routes.issue(issueArticle.slug, initialArticle.number) : routes.article(initialArticle.slug)} target="_blank">
{isIssue ? `discussion #${initialArticle.number}: ` : ''}"{initialFile?.titleSource}"
</a>
: <ArticleBy article={initialArticle} newTab={true}/>
}
</>
}
{isIssue && <> on <ArticleBy article={issueArticle} newTab={true}/></>}
</h1>
<div className="help"><a href={`${docsUrl}#ourbigbook-markup-quick-start`} target="_blank"><HelpIcon /> Learn how to write with our OurBigBook Markup format here!</a></div>
</div>
<div className="title-and-actions">
<input
type="text"
className="title"
placeholder={`${capitalize(itemType)} Title`}
value={file.titleSource}
onChange={handleTitle}
/>
<div className="actions">
<button
className="btn"
type="button"
disabled={isLoading}
onClick={handleSubmit}
ref={saveButtonElem}
tabIndex={-1}
>
<i className="ion-checkmark" /> {isNew ? `Publish ${capitalize(itemType)}` : 'Save Changes'}
</button>
<button
className="btn"
type="button"
onClick={handleCancel}
tabIndex={-1}
>
<CancelIcon /> Cancel
</button>
</div>
</div>
<ErrorList errors={titleErrors}/>
{(!isIssue && !isIndex) &&
<div ref={ourbigbookParentIdContainerElem}>
<Label label="Parent" />
<div className="parent-id-container">
<input
type="text"
className="title"
placeholder={parentTitleDisplay}
value={parentTitle}
onChange={handleParentTitle}
/>
</div>
<Label label={previousSiblingTitleDisplay} />
<div className="parent-id-container">
<input
type="text"
className="title"
placeholder={`Article with same parent that comes before this one. Empty means first child.`}
value={previousSiblingTitle}
onChange={handlePreviousSiblingTitle}
/>
</div>
</div>
}
<ErrorList errors={parentErrors}/>
<div
className="ourbigbook-editor"
ref={ourbigbookEditorElem}
>
</div>
</>
}
</div>
);
};
editor.isEditor = true;
return editor;
}