mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 14:01:58 -05:00
feat: visual editor (wip) + edit path + various fixes
This commit is contained in:
59 files changed
+15028
-1739
No files matched your search
+161
-2
@@ -988,6 +988,106 @@ async function routes(app: FastifyInstance) {
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* CONVERT PAGE TO ANOTHER EDITOR
|
||||
*/
|
||||
app.put<{
|
||||
Params: { siteId: string; pageId: string }
|
||||
Body: { editor: string; content?: string; render?: string; reasonForChange?: string }
|
||||
}>(
|
||||
'/sites/:siteId/pages/:pageId/editor',
|
||||
{
|
||||
/*
|
||||
No route-level `permissions`: that hook reads the group-wide list, and page permissions are
|
||||
granted by a group's RULES. Checked against the page in question below instead — which is
|
||||
also what lets a rule open one branch to somebody the group as a whole cannot write to.
|
||||
*/
|
||||
schema: {
|
||||
summary: 'Change which editor a page is written with',
|
||||
description:
|
||||
"A page has one editor and everybody who opens it gets that one, so this is how a page moves between them. Only between editors that produce the same content type — Markdown and Visual are two views of one markdown source — which is what makes it a change of tooling rather than a translation.\n\nThe optional `content` is that source written the way the new editor writes it; the Visual editor normalises spelling that markdown leaves free (quote style, list indentation, table padding), and sending it here spends that rewrite on one history version of its own instead of burying it in somebody's next real edit. Left out, the source is untouched.\n\nRecorded as an ordinary page edit, so the version it produces holds the page exactly as it now stands.",
|
||||
tags: ['Pages'],
|
||||
params: pageIdParam,
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['editor'],
|
||||
properties: {
|
||||
editor: {
|
||||
type: 'string',
|
||||
description: 'The editor to open this page with from now on.'
|
||||
},
|
||||
content: { type: 'string', description: 'The source, rewritten for the new editor.' },
|
||||
render: { type: 'string', description: 'The HTML that source renders to.' },
|
||||
reasonForChange: { type: 'string' }
|
||||
}
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'Page converted successfully',
|
||||
type: 'object',
|
||||
properties: {
|
||||
ok: { type: 'boolean' },
|
||||
message: { type: 'string' },
|
||||
page: { $ref: 'Page#' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const actor = actorFrom(req)
|
||||
if (!actor) {
|
||||
return reply.unauthorized('Converting a page requires a logged in user.')
|
||||
}
|
||||
const target = await WIKI.models.pages.getPage({
|
||||
siteId: req.params.siteId,
|
||||
id: req.params.pageId
|
||||
})
|
||||
if (!target) {
|
||||
return reply.notFound('This page does not exist.')
|
||||
}
|
||||
if (!mayOnPage(req, 'write:pages', target)) {
|
||||
return reply.forbidden('You are not allowed to edit this page.')
|
||||
}
|
||||
/*
|
||||
The site has to have the editor turned on. A page converted to an editor nobody can open is a
|
||||
page nobody can edit, and the Editors screen is where that is decided.
|
||||
*/
|
||||
const editors = WIKI.sites[req.params.siteId]?.config?.editors ?? {}
|
||||
if (!editors[req.body.editor]?.isActive) {
|
||||
return reply.badRequest('That editor is not enabled on this site.')
|
||||
}
|
||||
|
||||
const previousEditor = target.editor
|
||||
const change = await WIKI.models.pages.convertPage(
|
||||
req.params.siteId,
|
||||
req.params.pageId,
|
||||
req.body,
|
||||
actor
|
||||
)
|
||||
if (!change) {
|
||||
return reply.notFound('This page does not exist.')
|
||||
}
|
||||
const { page, versionId } = change
|
||||
|
||||
await audit(req, 'page', 'convertPage', {
|
||||
pageId: page.id,
|
||||
siteId: req.params.siteId,
|
||||
locale: page.locale,
|
||||
path: page.path,
|
||||
title: page.title,
|
||||
from: previousEditor,
|
||||
to: page.editor,
|
||||
versionId
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
message: 'Page converted successfully.',
|
||||
page
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* MOVE / RENAME PAGE
|
||||
*/
|
||||
@@ -1434,7 +1534,10 @@ async function routes(app: FastifyInstance) {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
path: { type: 'string' }
|
||||
path: { type: 'string' },
|
||||
// -> A path alone does not say where the page IS on a site that brackets its URLs by
|
||||
// locale, and the caller is about to build one
|
||||
locale: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1450,7 +1553,63 @@ async function routes(app: FastifyInstance) {
|
||||
if (!mayOnPage(req, 'read:pages', { ...target, siteId: req.params.siteId })) {
|
||||
return reply.notFound('No page uses this alias.')
|
||||
}
|
||||
// -> `id` and `path` are all the response schema keeps; the locale and tags were for the check
|
||||
// -> `id`, `path` and `locale` are what the response schema keeps; the tags were for the check
|
||||
return target
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* RESOLVE ID
|
||||
*/
|
||||
app.get<{ Params: { siteId: string; pageId: string } }>(
|
||||
'/sites/:siteId/pages/id/:pageId',
|
||||
{
|
||||
/*
|
||||
No route-level `permissions`, for the reason the alias route above gives: page permissions are
|
||||
granted by a group's RULES and are checked against the page itself.
|
||||
*/
|
||||
schema: {
|
||||
summary: 'Resolve a page id to its path',
|
||||
description:
|
||||
'The id half of the alias lookup, for the short link that names a page by the one thing about it that never changes: an alias can be retyped and a path can be moved, and neither breaks a link built on the id.\n\nAnswers 404 rather than 403 for a page the caller may not read — that a page exists at all is only theirs to know if they may read it.',
|
||||
tags: ['Pages'],
|
||||
params: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
siteId: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
},
|
||||
pageId: {
|
||||
type: 'string',
|
||||
format: 'uuid'
|
||||
}
|
||||
},
|
||||
required: ['siteId', 'pageId']
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: 'The page with this id',
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string', format: 'uuid' },
|
||||
path: { type: 'string' },
|
||||
locale: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
async (req, reply) => {
|
||||
const target = await WIKI.models.pages.getPathFromId(req.params.siteId, req.params.pageId)
|
||||
if (!target) {
|
||||
return reply.notFound('No page has this id.')
|
||||
}
|
||||
// -> Resolving an id tells the caller a page exists and where it is, which is only theirs to
|
||||
// know if they may read it
|
||||
if (!mayOnPage(req, 'read:pages', { ...target, siteId: req.params.siteId })) {
|
||||
return reply.notFound('No page has this id.')
|
||||
}
|
||||
return target
|
||||
}
|
||||
)
|
||||
|
||||
@@ -33,6 +33,11 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
type: 'boolean',
|
||||
description: 'False for blocks registered from the compiled block manifest.'
|
||||
},
|
||||
isChild: {
|
||||
type: 'boolean',
|
||||
description:
|
||||
'True for a block that only ever appears inside another one — `block-tab` inside `block-tabs`. It has no row of its own and nothing to switch on or off, since it is available wherever its parent is, and `isEnabled` is therefore always true for one. It is listed so that an editor can build a parameters form from the props it declares; a caller offering blocks to INSERT should leave it out.'
|
||||
},
|
||||
config: {
|
||||
type: 'object',
|
||||
additionalProperties: true
|
||||
@@ -60,7 +65,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['string', 'number', 'boolean', 'select'],
|
||||
enum: ['string', 'number', 'boolean', 'select', 'icon'],
|
||||
description: 'What kind of field to offer for it.'
|
||||
},
|
||||
label: {
|
||||
@@ -74,8 +79,20 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
},
|
||||
options: {
|
||||
type: 'array',
|
||||
description: 'Allowed values, for `select`.',
|
||||
items: { type: 'string' }
|
||||
description:
|
||||
'The choices a `select` offers. A plain string where the value IS the wording; an object where they differ — a tab\'s header level is offered as "Heading 3" and written as `3`.',
|
||||
items: {
|
||||
anyOf: [
|
||||
{ type: 'string' },
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string' },
|
||||
value: { type: 'string' }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
default: {
|
||||
description: 'Value the field starts on, and the one worth leaving out of the markup.'
|
||||
|
||||
@@ -172,6 +172,12 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
icon: { type: ['string', 'null'] },
|
||||
locale: { type: 'string' },
|
||||
editor: { type: 'string' },
|
||||
convertibleTo: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description:
|
||||
"The other editors this page could be opened with, which is every editor producing the same content type — Markdown and Visual are two views of one markdown source. Read-only, and not filtered by what the site has enabled: intersect it with the site's active editors. See `PUT /sites/{siteId}/pages/{pageId}/editor`."
|
||||
},
|
||||
contentType: { type: 'string' },
|
||||
publishState: { type: 'string', enum: ['draft', 'published', 'scheduled'] },
|
||||
publishStartDate: { type: ['string', 'null'], format: 'date-time' },
|
||||
|
||||
@@ -294,7 +294,7 @@ export async function registerSchemas(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
}
|
||||
},
|
||||
wysiwyg: {
|
||||
visual: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
isActive: {
|
||||
|
||||
+2
-2
@@ -193,8 +193,8 @@ editors:
|
||||
kroki: true
|
||||
plantuml: true
|
||||
multimdTable: true
|
||||
wysiwyg:
|
||||
contentType: html
|
||||
visual:
|
||||
contentType: markdown
|
||||
config: {}
|
||||
systemIds:
|
||||
localAuthId: '5a528c4c-0a82-4ad2-96a5-2b23811e6588'
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
-- The Visual editor replaces the `wysiwyg` placeholder, so every site's stored editor settings move
|
||||
-- with it: `editors.wysiwyg` becomes `editors.visual`, keeping whether the site had it switched on.
|
||||
--
|
||||
-- A data migration rather than a schema one. A site's settings live in the `config` JSONB column and
|
||||
-- are read back verbatim -- `reloadCache` does no merge with the defaults in `base.yml`, which are
|
||||
-- applied only when a site is created. So a key renamed in the code is simply missing from every site
|
||||
-- that already exists, and the frontend reads `editors.visual.isActive` directly: without this, the
|
||||
-- site payload has no such key, the site store throws while loading, and the whole app fails to
|
||||
-- start -- login page included.
|
||||
--
|
||||
-- Two statements rather than one, so that each is guarded by what it is actually for and neither
|
||||
-- does anything on a second run. Folded together with a COALESCE they would not be: with `wysiwyg`
|
||||
-- already gone, the fallback would win and overwrite `visual` with the default, switching the editor
|
||||
-- back on for a site that had deliberately turned it off.
|
||||
|
||||
-- The rename, for a site that has the old key.
|
||||
UPDATE "sites"
|
||||
SET "config" = jsonb_set(
|
||||
"config" #- '{editors,wysiwyg}',
|
||||
'{editors,visual}',
|
||||
"config" #> '{editors,wysiwyg}',
|
||||
true
|
||||
)
|
||||
WHERE jsonb_typeof("config" -> 'editors' -> 'wysiwyg') = 'object';
|
||||
--> statement-breakpoint
|
||||
-- And the default, for a site that had neither -- so that every row comes out of this with an
|
||||
-- `editors.visual` for the app to read. A `config` with no `editors` object at all is left alone:
|
||||
-- `jsonb_set` cannot create a key whose parent is missing, and that is not a shape this application
|
||||
-- produces.
|
||||
UPDATE "sites"
|
||||
SET "config" = jsonb_set(
|
||||
"config",
|
||||
'{editors,visual}',
|
||||
'{"isActive":true,"config":{}}'::jsonb,
|
||||
true
|
||||
)
|
||||
WHERE jsonb_typeof("config" -> 'editors') = 'object'
|
||||
AND "config" -> 'editors' -> 'visual' IS NULL;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -73,6 +73,25 @@ export function createDeferred<T = void>(): Deferred<T> {
|
||||
*/
|
||||
export const RESERVED_ROOT_FILES = new Set(['favicon.ico', 'robots.txt', 'sitemap.xml'])
|
||||
|
||||
/**
|
||||
* Root segments the frontend's own router owns, despite carrying no leading underscore.
|
||||
*
|
||||
* Everything the app mounts for itself sits under `/_…`, which is what makes `isPageUrl` a prefix
|
||||
* test rather than a list to keep in step. These are the exceptions, and each is an address a reader
|
||||
* is HANDED rather than a screen they browse to: the sign-in form, and the two short links that name
|
||||
* a page by something other than its path — `/a/<alias>` and `/i/<id>` — each of which looks its
|
||||
* target up and sends the reader on to it.
|
||||
*
|
||||
* Mistaking one for a page is not cosmetic. A page URL is locale-prefixed on a site that forces
|
||||
* prefixes, so `/login` was answered with a redirect to `/en/login` — a path no route matches, which
|
||||
* left the sign-in form unreachable the moment that setting was turned on. And a page URL is
|
||||
* described by the app shell, which answers 404 where the public may read nothing: on a private wiki
|
||||
* that is a 404 at the one address whose entire purpose is to let somebody in.
|
||||
*
|
||||
* Mirrored in the frontend's `helpers/pagePaths.js`, which asks the same question of the same URLs.
|
||||
*/
|
||||
export const RESERVED_ROOT_PATHS = new Set(['login', 'a', 'i'])
|
||||
|
||||
/**
|
||||
* Whether a URL addresses the page tree rather than the server itself.
|
||||
*
|
||||
@@ -84,8 +103,12 @@ export const RESERVED_ROOT_FILES = new Set(['favicon.ico', 'robots.txt', 'sitema
|
||||
* is still a page path, which is what lets the app shell answer 404 for one.
|
||||
*/
|
||||
export function isPageUrl(urlPath: string): boolean {
|
||||
const firstSegment = urlPath.split('/')[1] ?? ''
|
||||
return !firstSegment.startsWith('_') && !RESERVED_ROOT_FILES.has(firstSegment.toLowerCase())
|
||||
const firstSegment = (urlPath.split('/')[1] ?? '').toLowerCase()
|
||||
return (
|
||||
!firstSegment.startsWith('_') &&
|
||||
!RESERVED_ROOT_FILES.has(firstSegment) &&
|
||||
!RESERVED_ROOT_PATHS.has(firstSegment)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+92
-2
@@ -133,6 +133,7 @@
|
||||
"admin.audit.actions.cancelJob": "Cancelled a pending job",
|
||||
"admin.audit.actions.changePassword": "Changed their own password",
|
||||
"admin.audit.actions.checkForUpdate": "Checked for an update",
|
||||
"admin.audit.actions.convertPage": "Converted a page to another editor",
|
||||
"admin.audit.actions.createApiKey": "Created an API key",
|
||||
"admin.audit.actions.createApprovalRule": "Created an approval rule",
|
||||
"admin.audit.actions.createAuthStrategy": "Added an authentication strategy",
|
||||
@@ -428,8 +429,8 @@
|
||||
"admin.editors.subtitle": "Manage editors and their configuration",
|
||||
"admin.editors.title": "Editors",
|
||||
"admin.editors.useRenderingPipeline": "Uses the rendering pipeline.",
|
||||
"admin.editors.wysiwygDescription": "A visual WYSIWYG editor. The recommended editor for non-technical users.",
|
||||
"admin.editors.wysiwygName": "Visual Editor",
|
||||
"admin.editors.visualDescription": "A visual editor that writes Markdown behind the scenes, so a page can be moved between the two at any time. The recommended editor for non-technical users.",
|
||||
"admin.editors.visualName": "Visual Editor",
|
||||
"admin.extensions.incompatible": "not compatible",
|
||||
"admin.extensions.install": "Install",
|
||||
"admin.extensions.installFailed": "Failed to install extension.",
|
||||
@@ -1713,6 +1714,14 @@
|
||||
"common.comments.updateSuccess": "Comment updated.",
|
||||
"common.comments.viewDiscussion": "View Discussion",
|
||||
"common.comments.write": "Write",
|
||||
"common.createPage.api": "New API Documentation",
|
||||
"common.createPage.asciidoc": "New AsciiDoc Page",
|
||||
"common.createPage.blog": "New Blog Page",
|
||||
"common.createPage.channel": "New Discussion Space",
|
||||
"common.createPage.markdown": "New Markdown Page",
|
||||
"common.createPage.redirect": "New Redirection",
|
||||
"common.createPage.uploadAsset": "Upload Media Asset",
|
||||
"common.createPage.visual": "New Visual Page",
|
||||
"common.datetime": "{date} at {time}",
|
||||
"common.duration.days": "Day(s)",
|
||||
"common.duration.every": "Every",
|
||||
@@ -1872,6 +1881,20 @@
|
||||
"common.welcome.createhome": "Create Home Page",
|
||||
"common.welcome.subtitle": "Let's get started and create the home page.",
|
||||
"common.welcome.title": "Welcome to your wiki!",
|
||||
"convertPage.action": "Convert Page",
|
||||
"convertPage.convert": "Convert",
|
||||
"convertPage.describe.markdown": "Write the source directly, with a live preview beside it.",
|
||||
"convertPage.describe.visual": "Write the page as it will look. The source stays Markdown.",
|
||||
"convertPage.failed": "This page cannot be converted: {message}",
|
||||
"convertPage.intro": "This page is written with the {editor}. Choose the editor it should be opened with from now on — everyone who edits it gets the one you pick.",
|
||||
"convertPage.noSourceChange": "The source is already written the way this editor writes it, so nothing in the file changes.",
|
||||
"convertPage.reason": "Converted to the {editor}",
|
||||
"convertPage.success": "Page converted to the {editor}.",
|
||||
"convertPage.title": "Convert Page",
|
||||
"convertPage.wasRendered": "Now:",
|
||||
"convertPage.willReformat": "The source will be rewritten in this editor’s style — spacing, indentation and quote characters. The page renders exactly the same, and this is recorded as one version of its own.",
|
||||
"convertPage.wouldChange": "Converting would change how this page renders. Something in it cannot be written back exactly, so the conversion is refused rather than risk damaging the page.",
|
||||
"convertPage.wouldRender": "After:",
|
||||
"editor.assets.deleteAsset": "Delete Asset",
|
||||
"editor.assets.deleteAssetConfirm": "Are you sure you want to delete asset",
|
||||
"editor.assets.deleteAssetWarn": "This action cannot be undone!",
|
||||
@@ -2019,6 +2042,7 @@
|
||||
"editor.markup.markdownFormattingHelp": "Markdown Formatting Help",
|
||||
"editor.markup.noSelectionError": "Text must be selected first!",
|
||||
"editor.markup.orderedList": "Ordered List",
|
||||
"editor.markup.paragraph": "Paragraph",
|
||||
"editor.markup.strikethrough": "Strikethrough",
|
||||
"editor.markup.subscript": "Subscript",
|
||||
"editor.markup.superscript": "Superscript",
|
||||
@@ -2028,7 +2052,9 @@
|
||||
"editor.markup.taskListUnchecked": "Unchecked List Item",
|
||||
"editor.markup.togglePreviewPane": "Hide / Show Preview Pane",
|
||||
"editor.markup.toggleSpellcheck": "Toggle Spellcheck",
|
||||
"editor.markup.underline": "Underline",
|
||||
"editor.markup.unorderedList": "Unordered List",
|
||||
"editor.openFailed": "Could not open the editor for this page.",
|
||||
"editor.page": "Page",
|
||||
"editor.pageData.data": "Data",
|
||||
"editor.pageData.dragDropHint": "Drag-n-drop fields from the left onto this area to build your template structure.",
|
||||
@@ -2092,6 +2118,7 @@
|
||||
"editor.props.jsUnloadHint": "Execute javascript before the page content is destroyed",
|
||||
"editor.props.localeRelations": "Set Locale Relations",
|
||||
"editor.props.localeRelationsHint": "Link this page to the same page in other locales, so a reader switching language lands on it",
|
||||
"editor.props.pageId": "Page ID:",
|
||||
"editor.props.pageProperties": "Page Properties",
|
||||
"editor.props.password": "Password",
|
||||
"editor.props.passwordHint": "The page must be published and the user must have read access rights.",
|
||||
@@ -2181,6 +2208,69 @@
|
||||
"editor.unsaved.body": "You have unsaved changes. Are you sure you want to leave the editor and discard any modifications you made since the last save?",
|
||||
"editor.unsaved.title": "Discard Unsaved Changes?",
|
||||
"editor.unsavedWarning": "You have unsaved edits. Are you sure you want to leave the editor?",
|
||||
"editor.visual.abbreviation": "Abbreviation",
|
||||
"editor.visual.alert.caution": "Caution",
|
||||
"editor.visual.alert.important": "Important",
|
||||
"editor.visual.alert.note": "Note",
|
||||
"editor.visual.alert.tip": "Tip",
|
||||
"editor.visual.alert.title": "Alert",
|
||||
"editor.visual.alert.warning": "Warning",
|
||||
"editor.visual.block.content": "Content",
|
||||
"editor.visual.block.contentHint": "Edit what this block holds",
|
||||
"editor.visual.block.parameters": "Parameters",
|
||||
"editor.visual.block.parametersHint": "Edit this block’s parameters",
|
||||
"editor.visual.block.remove": "Remove",
|
||||
"editor.visual.block.removeHint": "Remove this block",
|
||||
"editor.visual.block.removeKeepHint": "Remove this block, keeping what it holds",
|
||||
"editor.visual.image.alt": "Alternative Text",
|
||||
"editor.visual.image.altHint": "What stands in for the picture where it cannot be seen. Leave it empty where the image is decoration.",
|
||||
"editor.visual.image.edit": "Edit…",
|
||||
"editor.visual.image.height": "Height",
|
||||
"editor.visual.image.natural": "Original size: {width} × {height}",
|
||||
"editor.visual.image.remove": "Remove",
|
||||
"editor.visual.image.replace": "Replace…",
|
||||
"editor.visual.image.replaceNotImage": "Only an image can replace an image.",
|
||||
"editor.visual.image.sizeHint": "In pixels, or a percentage such as 50%. Leave a field empty to let the image keep its proportions.",
|
||||
"editor.visual.image.sizeInvalid": "Must be a number of pixels, or a percentage.",
|
||||
"editor.visual.image.title": "Edit Image",
|
||||
"editor.visual.image.width": "Width",
|
||||
"editor.visual.indent": "Indent",
|
||||
"editor.visual.link.browse": "Browse…",
|
||||
"editor.visual.link.edit": "Edit",
|
||||
"editor.visual.link.newTab": "Open in a new tab",
|
||||
"editor.visual.link.remove": "Remove link",
|
||||
"editor.visual.link.text": "Text",
|
||||
"editor.visual.link.textHint": "The words the link is on.",
|
||||
"editor.visual.link.title": "Edit Link",
|
||||
"editor.visual.link.tooltip": "Tooltip",
|
||||
"editor.visual.link.tooltipHint": "Shown when a reader hovers the link. Optional.",
|
||||
"editor.visual.link.url": "URL",
|
||||
"editor.visual.name": "Visual",
|
||||
"editor.visual.outdent": "Outdent",
|
||||
"editor.visual.rawMarkup": "Raw markup",
|
||||
"editor.visual.redo": "Redo",
|
||||
"editor.visual.table.addColumnAfter": "Add Column After",
|
||||
"editor.visual.table.addColumnBefore": "Add Column Before",
|
||||
"editor.visual.table.addRowAfter": "Add Row After",
|
||||
"editor.visual.table.addRowBefore": "Add Row Before",
|
||||
"editor.visual.table.deleteColumn": "Remove Column",
|
||||
"editor.visual.table.deleteRow": "Remove Row",
|
||||
"editor.visual.table.deleteTable": "Delete Table",
|
||||
"editor.visual.table.mergeCells": "Merge Cells",
|
||||
"editor.visual.table.splitCell": "Split Cell",
|
||||
"editor.visual.table.toggleHeaderColumn": "Toggle Header Column",
|
||||
"editor.visual.table.toggleHeaderRow": "Toggle Header Row",
|
||||
"editor.visual.tableActions": "Table",
|
||||
"editor.visual.tabs.add": "Add a tab",
|
||||
"editor.visual.tabs.newTab": "New tab",
|
||||
"editor.visual.tabs.removeSetHint": "Remove the tabset, keeping what the tabs hold",
|
||||
"editor.visual.tabs.removeTab": "Remove tab",
|
||||
"editor.visual.tabs.removeTabHint": "Remove the tab that is showing, and its content",
|
||||
"editor.visual.tabs.settings": "Tab settings",
|
||||
"editor.visual.tabs.settingsHint": "Label, icon and header level of the tab that is showing",
|
||||
"editor.visual.tabs.switchHint": "Show this tab — double-click to rename",
|
||||
"editor.visual.tabs.untitled": "Tab {index}",
|
||||
"editor.visual.undo": "Undo",
|
||||
"error.ERR_ACCOUNT_ALREADY_EXISTS": "An account already exists for that email address.",
|
||||
"error.ERR_ACCOUNT_NOT_ALLOWED": "That account is not in a domain or organization this wiki allows. If you have a separate work account, try that one instead.",
|
||||
"error.ERR_ACR_NOT_SATISFIED": "Your identity provider did not confirm the kind of sign-in this wiki requires. A stronger sign-in method may be available; otherwise ask an administrator.",
|
||||
|
||||
@@ -28,6 +28,7 @@ export const AUDIT_ACTIONS = {
|
||||
page: [
|
||||
'createPage',
|
||||
'updatePage',
|
||||
'convertPage',
|
||||
'movePage',
|
||||
'deletePage',
|
||||
'renderPage',
|
||||
|
||||
@@ -6,11 +6,19 @@ import { blocks as blocksTable, sites as sitesTable } from '../db/schema.ts'
|
||||
/** One authorable attribute of a block, as its `static definition` describes it. */
|
||||
export interface BlockProp {
|
||||
name: string
|
||||
type: 'string' | 'number' | 'boolean' | 'select'
|
||||
/** `icon` is a string holding an Iconify reference, offered with the app's icon picker. */
|
||||
type: 'string' | 'number' | 'boolean' | 'select' | 'icon'
|
||||
label?: string
|
||||
hint?: string
|
||||
required?: boolean
|
||||
options?: string[]
|
||||
/**
|
||||
* The choices a `select` offers.
|
||||
*
|
||||
* Plain strings where the value IS the wording. An object where they differ -- a tab's header level
|
||||
* is offered as "Heading 3" and written as `3`, because `anchorHeadings` accepts a digit and
|
||||
* nothing else.
|
||||
*/
|
||||
options?: (string | { label: string; value: string })[]
|
||||
default?: string | number | boolean
|
||||
}
|
||||
|
||||
@@ -59,6 +67,14 @@ export interface SiteBlock {
|
||||
template: string
|
||||
/** Empty for a block that names no body editor, which is most of them. */
|
||||
contentEditor: string
|
||||
/**
|
||||
* Whether this block only ever appears inside another one — `block-tab` inside `block-tabs`.
|
||||
*
|
||||
* Such a block has no row of its own and nothing to switch on or off: it is available wherever its
|
||||
* parent is. It is still listed, because an editor has to be able to build a form for the props it
|
||||
* declares, and a caller offering blocks to INSERT is expected to skip it.
|
||||
*/
|
||||
isChild: boolean
|
||||
}
|
||||
|
||||
const blockSelection = {
|
||||
@@ -287,15 +303,46 @@ class Blocks {
|
||||
updated block's props are correct the moment it is deployed, with nothing to migrate — and a
|
||||
custom block, having no manifest entry, simply reports none.
|
||||
*/
|
||||
return (results as SiteBlock[]).map((row) => {
|
||||
const listed = (results as SiteBlock[]).map((row) => {
|
||||
const definition = this.definitions.find((d) => d.block === row.block)
|
||||
return {
|
||||
...row,
|
||||
props: definition?.props ?? [],
|
||||
template: definition?.template ?? '',
|
||||
contentEditor: definition?.contentEditor ?? ''
|
||||
contentEditor: definition?.contentEditor ?? '',
|
||||
isChild: false
|
||||
}
|
||||
})
|
||||
|
||||
/*
|
||||
And the child blocks, which have no row to have been read above — `registerForSite` skips them,
|
||||
because there is nothing about `block-tab` for a site to turn on or off independently of the
|
||||
tabset it lives in.
|
||||
|
||||
They are listed all the same, because the props they declare are what an editor builds its
|
||||
parameters form from: without them, the form for a tab's label, icon and header level cannot be
|
||||
built at all, and the button that opens it does nothing. `isChild` is how a caller offering
|
||||
blocks to insert knows to leave them out of the list.
|
||||
*/
|
||||
const children = this.definitions
|
||||
.filter((definition) => definition.isChild)
|
||||
.map((definition) => ({
|
||||
id: `child:${definition.block}`,
|
||||
block: definition.block,
|
||||
name: definition.name,
|
||||
description: definition.description,
|
||||
icon: definition.icon,
|
||||
// -> Available exactly when whatever holds it is, which is not a question this row can answer
|
||||
isEnabled: true,
|
||||
isCustom: false,
|
||||
config: {},
|
||||
props: definition.props ?? [],
|
||||
template: definition.template ?? '',
|
||||
contentEditor: definition.contentEditor ?? '',
|
||||
isChild: true
|
||||
}))
|
||||
|
||||
return [...listed, ...children]
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+139
-2
@@ -17,11 +17,44 @@ import type { StoragePageContent, StoragePageRef } from './storage.ts'
|
||||
/** What each editor produces, which is what the content column holds. */
|
||||
const EDITOR_CONTENT_TYPES: Record<string, string> = {
|
||||
markdown: 'markdown',
|
||||
visual: 'markdown',
|
||||
asciidoc: 'asciidoc',
|
||||
wysiwyg: 'html',
|
||||
redirect: 'redirect'
|
||||
}
|
||||
|
||||
/**
|
||||
* The editor a page of a given content type is opened with when nothing says which.
|
||||
*
|
||||
* `markdown` and `visual` both produce markdown — that is what lets a page be converted between them
|
||||
* without touching a byte of what it says — so the content type alone no longer identifies an editor.
|
||||
* A file being imported carries its source and not the editor somebody wrote it in, and this is the
|
||||
* answer for that case.
|
||||
*/
|
||||
const DEFAULT_EDITOR_FOR_CONTENT_TYPE: Record<string, string> = {
|
||||
markdown: 'markdown',
|
||||
asciidoc: 'asciidoc',
|
||||
redirect: 'redirect'
|
||||
}
|
||||
|
||||
/**
|
||||
* The editors that can open a page written by `editor`, itself excluded.
|
||||
*
|
||||
* Two editors are interchangeable when they produce the same content type, and only then: converting
|
||||
* is a change of how a page is EDITED, never of what it says, so there is no translation step and
|
||||
* nothing to lose. Markdown and Visual are that pair; asciidoc and redirect have nobody to swap with,
|
||||
* which is the honest answer rather than an offer to rewrite a page into a format it was not written
|
||||
* in.
|
||||
*/
|
||||
export function interchangeableEditors(editor: string): string[] {
|
||||
const contentType = EDITOR_CONTENT_TYPES[editor]
|
||||
if (!contentType) {
|
||||
return []
|
||||
}
|
||||
return Object.entries(EDITOR_CONTENT_TYPES)
|
||||
.filter(([name, type]) => type === contentType && name !== editor)
|
||||
.map(([name]) => name)
|
||||
}
|
||||
|
||||
/**
|
||||
* The extension a page's source is written under, by the content type its editor produces.
|
||||
*
|
||||
@@ -58,7 +91,7 @@ export function pageEditorForExtension(ext: string): string | null {
|
||||
if (!contentType) {
|
||||
return null
|
||||
}
|
||||
return Object.entries(EDITOR_CONTENT_TYPES).find(([, ct]) => ct === contentType)?.[0] ?? null
|
||||
return DEFAULT_EDITOR_FOR_CONTENT_TYPE[contentType] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,6 +191,8 @@ export interface Page {
|
||||
icon: string | null
|
||||
locale: string
|
||||
editor: string
|
||||
/** The other editors this page could be opened with — see `interchangeableEditors`. */
|
||||
convertibleTo: string[]
|
||||
contentType: string
|
||||
publishState: 'draft' | 'published' | 'scheduled'
|
||||
publishStartDate: Date | null
|
||||
@@ -466,6 +501,12 @@ class Pages {
|
||||
icon: row.icon,
|
||||
locale: row.locale,
|
||||
editor: row.editor,
|
||||
/*
|
||||
Which other editors could open this page — see `interchangeableEditors`. Derived rather than
|
||||
stored, and answered here so that the rule about what may be converted into what lives in one
|
||||
place: the client has only to intersect it with the editors the site has turned on.
|
||||
*/
|
||||
convertibleTo: interchangeableEditors(row.editor),
|
||||
contentType: row.contentType,
|
||||
publishState: row.publishState,
|
||||
publishStartDate: row.publishStartDate,
|
||||
@@ -1682,6 +1723,75 @@ class Pages {
|
||||
return { page: updated, versionId }
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a page with a different editor from now on.
|
||||
*
|
||||
* A page has exactly one editor and everyone who opens it gets that one — there is no scenario
|
||||
* where two people edit the same page through different editors, which is what makes converting a
|
||||
* deliberate act rather than a per-author preference.
|
||||
*
|
||||
* Only between editors that produce the same content type (`interchangeableEditors`), so nothing is
|
||||
* translated and nothing can be lost: Markdown and Visual are two views of one markdown source. The
|
||||
* content the caller sends is that source written the way the new editor writes it — the Visual
|
||||
* editor normalises quote style, list indentation and table padding, and doing it here, once, is
|
||||
* what keeps it out of the next author's first real edit.
|
||||
*
|
||||
* The editor is written before the save rather than as part of it, because `updatePage` will not
|
||||
* change it: which editor authored a page is not something an ordinary save may touch, and that
|
||||
* invariant is worth more than the one statement it costs here. `pageHistory` snapshots the row
|
||||
* after the write, so the single version this produces already records the page as converted.
|
||||
*
|
||||
* @returns The change, or null when the page is not there.
|
||||
*/
|
||||
async convertPage(
|
||||
siteId: string,
|
||||
id: string,
|
||||
{
|
||||
editor,
|
||||
content,
|
||||
render,
|
||||
reasonForChange
|
||||
}: { editor: string; content?: string; render?: string; reasonForChange?: string },
|
||||
actor: PageActor
|
||||
): Promise<PageChange | null> {
|
||||
const results = await WIKI.db
|
||||
.select({ id: pagesTable.id, editor: pagesTable.editor })
|
||||
.from(pagesTable)
|
||||
.where(and(eq(pagesTable.id, id), eq(pagesTable.siteId, siteId)))
|
||||
.limit(1)
|
||||
const existing = results[0]
|
||||
if (!existing) {
|
||||
return null
|
||||
}
|
||||
if (existing.editor === editor) {
|
||||
throw new CustomError('pageAlreadyUsesEditor', 'This page already uses that editor.')
|
||||
}
|
||||
if (!interchangeableEditors(existing.editor).includes(editor)) {
|
||||
throw new CustomError(
|
||||
'pageEditorNotInterchangeable',
|
||||
`A page written with the ${existing.editor} editor cannot be converted to ${editor}.`
|
||||
)
|
||||
}
|
||||
|
||||
await WIKI.db
|
||||
.update(pagesTable)
|
||||
.set({ editor, contentType: EDITOR_CONTENT_TYPES[editor] ?? 'text' })
|
||||
.where(eq(pagesTable.id, id))
|
||||
|
||||
/*
|
||||
Saved through the ordinary path, so the history version, the re-render, the search index and the
|
||||
copy on every storage target all happen exactly as they do for any other edit. `content` is
|
||||
absent when the new editor writes the source the same way the old one did, and `updatePage`
|
||||
leaves the column alone rather than blanking it.
|
||||
*/
|
||||
return this.updatePage(
|
||||
siteId,
|
||||
id,
|
||||
{ content, render, reasonForChange } as Partial<PageInput>,
|
||||
actor
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a page to another path, taking its tree entry with it.
|
||||
*/
|
||||
@@ -2360,6 +2470,33 @@ class Pages {
|
||||
return results[0] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the page with this id lives, for the short link that names it.
|
||||
*
|
||||
* The id half of `getPathFromAlias`, and it answers with the same four fields for the same reason:
|
||||
* the caller has to decide whether this reader may be told the page exists, and a page rule reads
|
||||
* the path, the locale and the tags to say so.
|
||||
*
|
||||
* An id is the one name for a page that never changes — an alias can be retyped and a path can be
|
||||
* moved — which is what makes it worth a link of its own.
|
||||
*/
|
||||
async getPathFromId(
|
||||
siteId: string,
|
||||
id: string
|
||||
): Promise<{ id: string; path: string; locale: string; tags: string[] } | null> {
|
||||
const results = await WIKI.db
|
||||
.select({
|
||||
id: pagesTable.id,
|
||||
path: pagesTable.path,
|
||||
locale: pagesTable.locale,
|
||||
tags: pagesTable.tags
|
||||
})
|
||||
.from(pagesTable)
|
||||
.where(and(eq(pagesTable.siteId, siteId), eq(pagesTable.id, id)))
|
||||
.limit(1)
|
||||
return results[0] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The locale a page belongs to when the request does not say.
|
||||
*/
|
||||
|
||||
+12
-2
@@ -211,7 +211,12 @@ class Sites {
|
||||
underline: true
|
||||
}
|
||||
},
|
||||
wysiwyg: {
|
||||
/*
|
||||
No config of its own, deliberately. The Visual editor writes markdown and its preview is
|
||||
rendered by the markdown pipeline, so it reads `markdown.config` — two settings blobs that
|
||||
had to agree would only be a way for them to disagree.
|
||||
*/
|
||||
visual: {
|
||||
isActive: true,
|
||||
config: {}
|
||||
}
|
||||
@@ -472,7 +477,12 @@ class Sites {
|
||||
underline: true
|
||||
}
|
||||
},
|
||||
wysiwyg: {
|
||||
/*
|
||||
No config of its own, deliberately. The Visual editor writes markdown and its preview is
|
||||
rendered by the markdown pipeline, so it reads `markdown.config` — two settings blobs that
|
||||
had to agree would only be a way for them to disagree.
|
||||
*/
|
||||
visual: {
|
||||
isActive: true,
|
||||
config: {}
|
||||
}
|
||||
|
||||
@@ -39,16 +39,36 @@ export class BlockTabElement extends HTMLElement {
|
||||
required: true
|
||||
},
|
||||
{
|
||||
/*
|
||||
`icon` rather than `string`: the value is an Iconify reference, which is something to pick
|
||||
rather than to spell, and the editor offers the same picker the rest of the app uses.
|
||||
*/
|
||||
name: 'icon',
|
||||
type: 'string',
|
||||
type: 'icon',
|
||||
label: 'Icon',
|
||||
hint: 'Iconify reference drawn to the left of the label, e.g. mdi:language-python.'
|
||||
},
|
||||
{
|
||||
/*
|
||||
The level this tab is listed at in the page contents, as a choice rather than a number:
|
||||
`anchorHeadings` accepts `1`-`6` and nothing else, so a free number field could only be
|
||||
filled in wrongly. The empty value is what "Normal" means -- no attribute is written for it,
|
||||
and the tab is an ordinary tab.
|
||||
*/
|
||||
name: 'header',
|
||||
type: 'number',
|
||||
type: 'select',
|
||||
label: 'Header Level',
|
||||
hint: 'A level from 1 to 6 lists this tab in the page contents under its label, and a reader clicking it there opens the tab. Empty for an ordinary tab.'
|
||||
hint: 'Lists this tab in the page contents under its label, at the level chosen. A reader clicking it there opens the tab.',
|
||||
default: '',
|
||||
options: [
|
||||
{ label: 'Normal', value: '' },
|
||||
{ label: 'Heading 1', value: '1' },
|
||||
{ label: 'Heading 2', value: '2' },
|
||||
{ label: 'Heading 3', value: '3' },
|
||||
{ label: 'Heading 4', value: '4' },
|
||||
{ label: 'Heading 5', value: '5' },
|
||||
{ label: 'Heading 6', value: '6' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Generated
+189
-549
File diff suppressed because it is too large.
Load diff
+18
-1
@@ -14,7 +14,8 @@
|
||||
"icons": "node scripts/generate-icons.mjs",
|
||||
"icons:check": "node scripts/generate-icons.mjs --check",
|
||||
"emoji": "node scripts/generate-emoji.mjs",
|
||||
"emoji:check": "node scripts/generate-emoji.mjs --check"
|
||||
"emoji:check": "node scripts/generate-emoji.mjs --check",
|
||||
"visual:check": "node scripts/check-visual-roundtrip.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
@@ -49,6 +50,18 @@
|
||||
"mitt": "3.0.1",
|
||||
"monaco-editor": "0.56.0",
|
||||
"pinia": "4.0.2",
|
||||
"prosemirror-commands": "1.7.2",
|
||||
"prosemirror-gapcursor": "1.4.1",
|
||||
"prosemirror-history": "1.5.0",
|
||||
"prosemirror-inputrules": "1.5.1",
|
||||
"prosemirror-keymap": "1.2.3",
|
||||
"prosemirror-markdown": "1.13.7",
|
||||
"prosemirror-model": "1.25.11",
|
||||
"prosemirror-schema-list": "1.5.1",
|
||||
"prosemirror-state": "1.4.4",
|
||||
"prosemirror-tables": "1.8.5",
|
||||
"prosemirror-transform": "1.12.1",
|
||||
"prosemirror-view": "1.42.3",
|
||||
"semver": "7.8.5",
|
||||
"slugify": "1.6.9",
|
||||
"sortablejs": "1.15.7",
|
||||
@@ -63,6 +76,7 @@
|
||||
"vue3-otp-input": "0.5.40",
|
||||
"vuedraggable": "4.1.0",
|
||||
"y-monaco": "0.1.6",
|
||||
"y-prosemirror": "1.3.7",
|
||||
"y-protocols": "1.0.7",
|
||||
"y-websocket": "3.0.0",
|
||||
"yjs": "13.6.31",
|
||||
@@ -84,6 +98,9 @@
|
||||
"overrides": {
|
||||
"markdown-it-mdc": {
|
||||
"markdown-it": "$markdown-it"
|
||||
},
|
||||
"prosemirror-markdown": {
|
||||
"markdown-it": "$markdown-it"
|
||||
}
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/*
|
||||
Checks that the Visual editor can open a page and write it back without changing it.
|
||||
|
||||
This is the guarantee the whole editor rests on. It edits a ProseMirror document and stores markdown,
|
||||
so every save goes source → document → source; if that round trip is not faithful, opening a page and
|
||||
pressing Save damages it, and nothing else in the app would notice. The failure is silent, delayed,
|
||||
and lands on content somebody wrote.
|
||||
|
||||
What is compared is the RENDER, not the text. The source is expected to come back spelled differently
|
||||
— that is what converting a page to this editor does on purpose, once, as its own history version — so
|
||||
comparing strings would fail on every case for the wrong reason. Two sources that render identically
|
||||
say the same thing, which is the property that actually matters.
|
||||
|
||||
The corpus is `src/helpers/sampleContent.js`, the pages a wiki seeds itself with, plus the constructs
|
||||
below. The sample pages are the more valuable half: they are written as documentation rather than as
|
||||
tests, so they exercise the pipeline the way a real page does.
|
||||
|
||||
Usage: node scripts/check-visual-roundtrip.mjs [--verbose]
|
||||
Exits non-zero on the first construct that does not survive.
|
||||
*/
|
||||
import { existsSync } from 'node:fs'
|
||||
import { registerHooks } from 'node:module'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const SRC = new URL('../src/', import.meta.url)
|
||||
|
||||
/*
|
||||
The resolutions Vite does and Node does not, so that the app's own modules can be imported here
|
||||
unchanged: the `@` alias, extensionless relative imports, and the shim for the `markdown-it/lib/*`
|
||||
subpath `markdown-it-mdc` still asks for and markdown-it 15 no longer exports. All three mirror
|
||||
`vite.config.js`, which is where they are configured for the app itself.
|
||||
*/
|
||||
registerHooks({
|
||||
resolve(specifier, context, next) {
|
||||
if (specifier === 'markdown-it/lib/token.mjs') {
|
||||
return next(new URL('renderers/modules/markdown-it-token.js', SRC).href, context)
|
||||
}
|
||||
|
||||
/*
|
||||
Anything that addresses this app's own source: the `@` alias, or a relative import from a file
|
||||
already inside it. Scoped that tightly because `registerHooks` is synchronous and so also sits in
|
||||
front of CommonJS `require`, where handing back a `file:` URL for a package's own internal
|
||||
`./patterns.js` is not something the CJS loader accepts.
|
||||
*/
|
||||
const target = specifier.startsWith('@/')
|
||||
? new URL(specifier.slice(2), SRC)
|
||||
: specifier.startsWith('.') && context.parentURL?.startsWith(SRC.href)
|
||||
? new URL(specifier, context.parentURL)
|
||||
: null
|
||||
if (!target) {
|
||||
return next(specifier, context)
|
||||
}
|
||||
if (existsSync(fileURLToPath(target))) {
|
||||
return next(target.href, context)
|
||||
}
|
||||
// -> Extensionless, which the frontend writes throughout and Node does not resolve
|
||||
for (const ext of ['.js', '.mjs', '/index.js']) {
|
||||
const candidate = new URL(target.href + ext)
|
||||
if (existsSync(fileURLToPath(candidate))) {
|
||||
return next(candidate.href, context)
|
||||
}
|
||||
}
|
||||
return next(target.href, context)
|
||||
}
|
||||
})
|
||||
|
||||
const { MarkdownRenderer } = await import('../src/renderers/markdown.js')
|
||||
const { createParser } = await import('../src/editor/visual/parse.js')
|
||||
const { serialize } = await import('../src/editor/visual/serialize.js')
|
||||
const { SAMPLE_PAGES } = await import('../src/helpers/sampleContent.js')
|
||||
|
||||
const VERBOSE = process.argv.includes('--verbose')
|
||||
|
||||
/**
|
||||
* The site configurations to check under.
|
||||
*
|
||||
* Not a formality: the config decides what a token MEANS, so it decides what the parser has to build.
|
||||
* `underline` is the one that bites — with it on, `_x_` is an underline rather than an emphasis, and
|
||||
* an editor that normalised it to `*x*` would be changing the page rather than reformatting it.
|
||||
*/
|
||||
const CONFIGS = {
|
||||
default: { allowHTML: true, linkify: true, lineBreaks: true, multimdTable: true },
|
||||
underline: {
|
||||
allowHTML: true,
|
||||
linkify: true,
|
||||
lineBreaks: true,
|
||||
multimdTable: true,
|
||||
underline: true
|
||||
},
|
||||
plain: {
|
||||
allowHTML: true,
|
||||
linkify: false,
|
||||
lineBreaks: false,
|
||||
multimdTable: false,
|
||||
typographer: true
|
||||
}
|
||||
}
|
||||
|
||||
/** One construct each, so a failure names the thing that broke rather than a whole page. */
|
||||
const CONSTRUCTS = {
|
||||
'inline marks': 'Plain **bold**, *italic*, `code`, ~~strike~~, ==mark==, ~sub~ and ^sup^.',
|
||||
'strong around code': '**`name`** in bold',
|
||||
headings: '# One\n\n## Two {#custom .cls}',
|
||||
'setext heading': 'Title\n=====',
|
||||
links: 'A [link](https://example.com "Title") here.',
|
||||
/*
|
||||
A link's `{…}` suffix, and `target` above all: it is how both editors write "open in a new tab",
|
||||
and it went missing on every round trip until the link mark learned to carry it. Nothing here
|
||||
caught that, because there was no case for it -- which is the argument for adding one whenever a
|
||||
construct turns out to be droppable.
|
||||
*/
|
||||
'link opening a new tab': 'A [new tab](https://example.com){target="_blank"} here.',
|
||||
'link with id and class': 'A [classed](https://example.com){#x .cls} here.',
|
||||
'link with a title and a target': 'A [both](https://example.com "Tip"){target="_blank"} here.',
|
||||
'image with size': '',
|
||||
// -> Both halves of the suffix are optional, and a height on its own went missing on the way back
|
||||
'image with a width only': '',
|
||||
'image with a height only': '',
|
||||
'image sized in percent': '',
|
||||
'bullet list': '- one\n- two\n - nested',
|
||||
'bullet list with stars': '* one\n* two',
|
||||
'ordered list': '1. one\n2. two',
|
||||
'ordered list with parens': '1) one\n2) two',
|
||||
'loose list': '- one\n\n- two',
|
||||
'task list': '- [x] done\n- [ ] todo',
|
||||
blockquote: '> quoted\n>\n> second',
|
||||
alert: '> [!WARNING] Mind the gap\n> Body of the alert.',
|
||||
'alert without a title': '> [!NOTE]\n> Body of the note.',
|
||||
fence: '```js\nconst x = 1\n```',
|
||||
'fence with attributes': '```js title="Demo" linesStart="3"\nconst x = 1\n```',
|
||||
'indented code': ' const x = 1\n const y = 2',
|
||||
'horizontal rule': 'above\n\n---\n\nbelow',
|
||||
table: '| A | B |\n|:--|--:|\n| 1 | 2 |',
|
||||
'table with escaped pipes': '| A | B |\n| --- | --- |\n| `a\\|b` | c |',
|
||||
'html block': '<div class="raw">HTML block</div>',
|
||||
'html inline': 'Press <kbd>Ctrl</kbd> now.',
|
||||
'emoji and icon': 'Emoji :smile: and icon :mdi:home: here.',
|
||||
footnote: 'Ref[^a] here.\n\n[^a]: The note body.',
|
||||
abbreviation: '*[HTML]: Hyper Text Markup Language\n\nHTML rocks',
|
||||
'definition list': 'Term\n: Definition one\n: Definition two',
|
||||
'inline span': 'A [span]{.cls #x} end.',
|
||||
'block with no body': '::block-map{lat="1" lng="2"}\n::',
|
||||
'block with a fenced body':
|
||||
'::block-diagram{caption="A"}\n```mermaid\nflowchart LR\n A --> B\n```\n::',
|
||||
'block with page content': '::block-spoiler{label="Peek"}\nHidden prose here.\n::',
|
||||
tabset:
|
||||
':::block-tabs\n::block-tab{label="One"}\nFirst panel.\n::\n\n::block-tab{label="Two"}\nSecond panel.\n::\n:::',
|
||||
'deeply nested blocks':
|
||||
'::::block-tabs\n:::block-tab{label="A"}\n::block-map{lat="1"}\n::\n:::\n::::',
|
||||
'soft break': 'line one\nline two',
|
||||
'hard break': 'line one\\\nline two',
|
||||
'block attributes': 'Some text\n{.is-warning}',
|
||||
'escaped characters': 'A \\* not emphasis \\_ and \\[not a link\\]',
|
||||
'code containing backticks': '``a ` b``'
|
||||
}
|
||||
|
||||
/**
|
||||
* A render reduced to what it says.
|
||||
*
|
||||
* The preview's line markers go — they are scaffolding the server strips before storing anything — and
|
||||
* whitespace between tags is collapsed, since the serialiser may put a construct on a different line
|
||||
* without changing a word of the page.
|
||||
*/
|
||||
function meaningOf(html) {
|
||||
return html
|
||||
.replace(/\s*data-line="\d+"/g, '')
|
||||
.replace(/\bclass="line"/g, '')
|
||||
.replace(/\bline\b ?/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function check(name, source, config) {
|
||||
// -> A fresh renderer per render: footnote ids accumulate in the environment, so one instance
|
||||
// rendering twice reports the second set differently for reasons that are not the page's
|
||||
const render = (text) => new MarkdownRenderer(config).render(text, { pagePath: 'demo/page' })
|
||||
const parser = createParser(new MarkdownRenderer(config), config)
|
||||
|
||||
let rewritten
|
||||
try {
|
||||
rewritten = serialize(parser.parse(source, 'demo/page'))
|
||||
} catch (err) {
|
||||
return { ok: false, why: `threw while round-tripping: ${err.message}` }
|
||||
}
|
||||
const before = meaningOf(render(source))
|
||||
const after = meaningOf(render(rewritten))
|
||||
if (before === after) {
|
||||
return { ok: true, rewritten }
|
||||
}
|
||||
let at = 0
|
||||
while (at < before.length && at < after.length && before[at] === after[at]) {
|
||||
at++
|
||||
}
|
||||
const from = Math.max(0, at - 60)
|
||||
return {
|
||||
ok: false,
|
||||
why: 'renders differently afterwards',
|
||||
rewritten,
|
||||
before: before.slice(from, at + 120),
|
||||
after: after.slice(from, at + 120)
|
||||
}
|
||||
}
|
||||
|
||||
let failures = 0
|
||||
let checked = 0
|
||||
|
||||
for (const [configName, config] of Object.entries(CONFIGS)) {
|
||||
const cases = Object.entries(CONSTRUCTS)
|
||||
for (const page of SAMPLE_PAGES) {
|
||||
if (page.content?.trim()) {
|
||||
cases.push([`sample page: ${page.title ?? page.path}`, page.content])
|
||||
}
|
||||
}
|
||||
for (const [name, source] of cases) {
|
||||
checked++
|
||||
const result = check(name, source, config)
|
||||
if (result.ok) {
|
||||
if (VERBOSE) {
|
||||
console.log(` ok [${configName}] ${name}`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
failures++
|
||||
console.error(`\nFAIL [${configName}] ${name} — ${result.why}`)
|
||||
console.error(`--- source ---\n${source}`)
|
||||
console.error(`--- written back ---\n${result.rewritten ?? '(nothing)'}`)
|
||||
if (result.before !== undefined) {
|
||||
console.error(`--- renders as ---\n${result.before}`)
|
||||
console.error(`--- would render as ---\n${result.after}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures > 0) {
|
||||
console.error(`\n${failures} of ${checked} did not survive the round trip.`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`Visual editor round trip: ${checked} checks passed.`)
|
||||
@@ -13,7 +13,7 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { setCssVar } from '@/helpers/cssVars'
|
||||
import { splitLocalePath, stripPageExtension } from '@/helpers/pagePaths'
|
||||
import { isPagePath, splitLocalePath, stripPageExtension } from '@/helpers/pagePaths'
|
||||
import { useDark } from '@/composables/dark'
|
||||
import { notify } from '@/composables/notify'
|
||||
|
||||
@@ -278,8 +278,8 @@ router.beforeEach(async (to, from) => {
|
||||
bootstrap above, since that is where the site's extensions come from. A `/_` route is the app
|
||||
itself rather than a page, and is left alone as it is by the server.
|
||||
*/
|
||||
const isPagePath = !to.path.startsWith('/_')
|
||||
const withoutExtension = isPagePath ? stripPageExtension(to.path, siteStore.pageExtensions) : null
|
||||
const isPage = isPagePath(to.path)
|
||||
const withoutExtension = isPage ? stripPageExtension(to.path, siteStore.pageExtensions) : null
|
||||
if (withoutExtension) {
|
||||
return { path: withoutExtension, query: to.query, hash: to.hash, replace: true }
|
||||
}
|
||||
@@ -292,7 +292,7 @@ router.beforeEach(async (to, from) => {
|
||||
locale's short code -- `/fr` for `fr-FR` -- the same segment its content is filed under.
|
||||
*/
|
||||
if (
|
||||
isPagePath &&
|
||||
isPage &&
|
||||
siteStore.locales.forcePrefix &&
|
||||
!splitLocalePath(to.path, siteStore.localePrefixes)
|
||||
) {
|
||||
@@ -317,7 +317,7 @@ router.beforeEach(async (to, from) => {
|
||||
site's primary is also the fallback for a first visit, and for a stored locale the site no longer
|
||||
offers.
|
||||
*/
|
||||
const pageLocale = isPagePath
|
||||
const pageLocale = isPage
|
||||
? (splitLocalePath(to.path, siteStore.localePrefixes)?.locale ?? siteStore.locales.primary)
|
||||
: null
|
||||
if (pageLocale) {
|
||||
@@ -339,7 +339,7 @@ router.beforeEach(async (to, from) => {
|
||||
dropping the last page's permissions on the way out of the page view, which takes no request at
|
||||
all. A path with no page behind it has nothing to carry them, and asks in `pages/Index.vue`.
|
||||
*/
|
||||
if (to.path.startsWith('/_')) {
|
||||
if (!isPagePath(to.path)) {
|
||||
userStore.$patch({ pagePermissions: [] })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
never waits on (or depends on) the icon service. Regenerate with `npm run icons` after adding or
|
||||
removing an icon; `check-icons.mjs` fails the build if this drifts.
|
||||
|
||||
281 icons.
|
||||
272 icons.
|
||||
*/
|
||||
export const BUNDLED_ICONS = {
|
||||
"la:angle-right": {"body":"<path fill=\"currentColor\" d=\"M12.969 4.281L11.53 5.72L21.812 16l-10.28 10.281l1.437 1.438l11-11l.687-.719l-.687-.719z\"/>","width":32,"height":32},
|
||||
@@ -102,6 +102,7 @@ export const BUNDLED_ICONS = {
|
||||
"la:otter": {"body":"<path fill=\"currentColor\" d=\"M14.5 5c-3.711 0-7.057 1.584-8.594 4.002A3.003 3.003 0 0 0 3 12c0 .899.407 2.021 1 2.818V27h2V14.068l-.268-.287C5.3 13.314 5 12.47 5 12a1 1 0 0 1 1-1q.097 0 .23.031l.784.188l.347-.729C8.347 8.435 11.283 7 14.5 7h3c3.217 0 6.153 1.435 7.139 3.49l.347.729l.784-.188q.133-.03.23-.031a1 1 0 0 1 1 1c0 .469-.3 1.314-.732 1.781l-.268.287V27h2V14.818c.593-.797 1-1.919 1-2.818a3.003 3.003 0 0 0-2.906-2.998C24.557 6.584 21.21 5 17.5 5zM10 12a1 1.5 0 0 0 0 3a1 1.5 0 0 0 0-3m12 0a1 1.5 0 0 0 0 3a1 1.5 0 0 0 0-3m-6 1.018c-.915 0-1.83.323-2.629.968c-3.557 2.878-3.64 2.95-3.887 3.157l-.107.09C8.489 17.974 8 18.958 8 20c0 2.206 1.794 4 4 4c1.498 0 2.914-.36 4-1.006C17.086 23.64 18.502 24 20 24c2.206 0 4-1.794 4-4c0-1.041-.489-2.024-1.377-2.768l-.107-.09c-.246-.206-.33-.279-3.887-3.158c-.798-.644-1.714-.966-2.629-.966m0 1.986c.463 0 .927.179 1.371.537c3.527 2.854 3.613 2.926 3.856 3.13l.113.097c.3.252.66.669.66 1.232c0 1.103-.897 2-2 2c-1.339 0-2.6-.371-3.371-.994l-.596-.48c.075-.87.353-1.526.967-1.526c1.105 0 2-.202 2-.727C19 17.57 17.657 17 16 17s-3 .57-3 1.273c0 .525.895.727 2 .727c.614 0 .892.656.967 1.525l-.596.48C14.6 21.63 13.34 22 12 22c-1.103 0-2-.897-2-2c0-.562.36-.98.662-1.232l.113-.096c.244-.205.328-.276 3.854-3.129c.445-.36.908-.54 1.371-.54z\"/>","width":32,"height":32},
|
||||
"la:paper-plane": {"body":"<path fill=\"currentColor\" d=\"m3.594 5.344l.437 1.875L5.97 16l-1.94 8.781l-.437 1.875l1.781-.718l22-9L29.656 16l-2.281-.938l-22-9zm2.781 3.312L21.906 15H7.781zM7.781 17h14.125L6.375 23.344z\"/>","width":32,"height":32},
|
||||
"la:pen": {"body":"<path fill=\"currentColor\" d=\"M23.906 3.969A4.1 4.1 0 0 0 21 5.188L5.187 21l-.062.313l-1.094 5.5l-.312 1.468l1.469-.312l5.5-1.094l.312-.063L26.813 11a4.075 4.075 0 0 0 0-5.813a4.1 4.1 0 0 0-2.907-1.218m0 1.906c.504 0 1.012.23 1.5.719c.973.972.973 2.027 0 3l-.718.687l-2.97-2.969l.688-.718c.489-.489.996-.719 1.5-.719m-3.593 2.844l2.968 2.969L11.188 23.78a6.8 6.8 0 0 0-2.97-2.968zM6.938 22.438a4.73 4.73 0 0 1 2.625 2.625l-3.282.656z\"/>","width":32,"height":32},
|
||||
"la:pen-fancy": {"body":"<path fill=\"currentColor\" d=\"M23.813 4.031c-1.09 0-2.2.418-3.032 1.25L11.5 14.563l-5.469 2.093l-.531.219l-.094.563L4 26.843l-.188 1.343L5.157 28l9.407-1.406l.562-.094l.219-.531l1.969-5.188l.5-.468l9-9c1.613-1.614 1.644-4.204.125-5.876l-.125-.156a4.22 4.22 0 0 0-3-1.25zm0 1.969c.562 0 1.125.25 1.593.719c.938.937.938 2.25 0 3.187l-5.031 5.031l-3.188-3.187l5.032-5.031C22.687 6.25 23.25 6 23.812 6zm-8.063 7.188l3.188 3.187l-1.813 1.813L13.937 15zm-3.344 3.156h.031l3.22 3.218l-1.97 5.157l-5.843.843l2.687-2.687c.055.004.102.031.156.031c.883 0 1.626-.71 1.626-1.593s-.743-1.625-1.626-1.625s-1.593.742-1.593 1.625c0 .054.027.101.031.156l-2.688 2.687l.844-5.843z\"/>","width":32,"height":32},
|
||||
"la:pen-nib": {"body":"<path fill=\"currentColor\" d=\"m22 3.586l-4.521 4.521l-6.74 1.928a4.06 4.06 0 0 0-2.802 2.65L3.86 25.274l1.434 1.434l1.434 1.434l12.593-4.08a4.06 4.06 0 0 0 2.64-2.788l1.929-6.748L28.414 10zm0 2.828L25.586 10L23 12.586L19.414 9zm-4.29 3.711l4.165 4.164l-1.842 6.45a2.06 2.06 0 0 1-1.336 1.421L7.69 25.725l5.795-5.795A2 2 0 0 0 14 20a2 2 0 0 0 0-4a2 2 0 0 0-1.93 2.516L6.275 24.31l3.563-11a2.07 2.07 0 0 1 1.437-1.348l6.436-1.838z\"/>","width":32,"height":32},
|
||||
"la:play": {"body":"<path fill=\"currentColor\" d=\"M9 5.156v21.688l1.531-1L25.844 16L10.53 6.156zm2 3.657L22.156 16L11 23.188z\"/>","width":32,"height":32},
|
||||
"la:plug": {"body":"<path fill=\"currentColor\" d=\"m22 3.594l-4 3.969l-2.281-2.282L14.28 6.72l.75.75l-5.125 5.125a3.126 3.126 0 0 0 0 4.406l1.844 1.844l-7.469 7.437l1.44 1.438l7.437-7.469L15 22.094a3.126 3.126 0 0 0 4.406 0l5.125-5.125l.75.75l1.438-1.438L24.437 14l3.97-4L27 8.594l-4 3.969L19.437 9l3.97-4zm-5.563 5.281l6.688 6.688L18 20.686c-.387.387-1.207.387-1.594 0l-5.093-5.093c-.387-.387-.387-1.207 0-1.594z\"/>","width":32,"height":32},
|
||||
@@ -162,6 +163,7 @@ export const BUNDLED_ICONS = {
|
||||
"mdi:account-group": {"body":"<path fill=\"currentColor\" d=\"M12 5.5A3.5 3.5 0 0 1 15.5 9a3.5 3.5 0 0 1-3.5 3.5A3.5 3.5 0 0 1 8.5 9A3.5 3.5 0 0 1 12 5.5M5 8c.56 0 1.08.15 1.53.42c-.15 1.43.27 2.85 1.13 3.96C7.16 13.34 6.16 14 5 14a3 3 0 0 1-3-3a3 3 0 0 1 3-3m14 0a3 3 0 0 1 3 3a3 3 0 0 1-3 3c-1.16 0-2.16-.66-2.66-1.62a5.54 5.54 0 0 0 1.13-3.96c.45-.27.97-.42 1.53-.42M5.5 18.25c0-2.07 2.91-3.75 6.5-3.75s6.5 1.68 6.5 3.75V20h-13zM0 20v-1.5c0-1.39 1.89-2.56 4.45-2.9c-.59.68-.95 1.62-.95 2.65V20zm24 0h-3.5v-1.75c0-1.03-.36-1.97-.95-2.65c2.56.34 4.45 1.51 4.45 2.9z\"/>","width":24,"height":24},
|
||||
"mdi:alert": {"body":"<path fill=\"currentColor\" d=\"M13 14h-2V9h2m0 9h-2v-2h2M1 21h22L12 2z\"/>","width":24,"height":24},
|
||||
"mdi:alert-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m8 10V7h-2v6zm0 4v-2h-2v2z\"/>","width":24,"height":24},
|
||||
"mdi:alert-box-outline": {"body":"<path fill=\"currentColor\" d=\"M19 19H5V5h14m0-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2m-8 12h2v2h-2zm0-8h2v6h-2z\"/>","width":24,"height":24},
|
||||
"mdi:arrow-right": {"body":"<path fill=\"currentColor\" d=\"M4 11v2h12l-5.5 5.5l1.42 1.42L19.84 12l-7.92-7.92L10.5 5.5L16 11z\"/>","width":24,"height":24},
|
||||
"mdi:arrow-vertical-lock": {"body":"<path fill=\"currentColor\" d=\"M18.8 11V9.5C18.8 8.1 17.4 7 16 7s-2.8 1.1-2.8 2.5V11c-.6 0-1.2.6-1.2 1.2v3.5c0 .7.6 1.3 1.2 1.3h5.5c.7 0 1.3-.6 1.3-1.2v-3.5c0-.7-.6-1.3-1.2-1.3m-1.3 0h-3V9.5c0-.8.7-1.3 1.5-1.3s1.5.5 1.5 1.3zM9 6h3L8 2L4 6h3v12H4l4 4l4-4H9z\"/>","width":24,"height":24},
|
||||
"mdi:basketball": {"body":"<path fill=\"currentColor\" d=\"M2.34 14.63c.6-.22 1.22-.33 1.88-.33q2.01 0 3.51 1.26L4.59 18.7a10.6 10.6 0 0 1-2.25-4.07M15.56 9.8c1.97 1.47 4.1 1.83 6.38 1.08c.03.21.06.59.06 1.12c0 1.03-.25 2.18-.72 3.45c-.47 1.26-1.05 2.28-1.73 3.05l-6.33-6.31zm-6.79 6.84c1.06 1.53 1.28 3.2.65 5.02c-1.42-.41-2.69-1.05-3.75-1.93zm3.42-3.42l6.31 6.33c-2.17 1.9-4.72 2.7-7.62 2.39c.21-.66.32-1.38.32-2.16c0-.62-.14-1.35-.42-2.18s-.61-1.51-.98-2.04zM8.81 14.5a6.7 6.7 0 0 0-3.23-1.59c-1.22-.23-2.39-.16-3.52.22c-.03-.22-.06-.6-.06-1.13c0-1.03.25-2.18.72-3.45c.47-1.26 1.05-2.28 1.73-3.05l6.66 6.69zm6.75-6.77c-1.34-1.65-1.65-3.45-.93-5.39c.62.16 1.33.46 2.13.92c.79.45 1.44.9 1.94 1.33zm6.1 1.65c-.6.21-1.22.32-1.88.32c-1.09 0-2.14-.32-3.14-.98l3.09-3.05c.88 1.1 1.52 2.33 1.93 3.71m-9.47 1.73L5.5 4.45c2.17-1.9 4.72-2.7 7.63-2.39q-.33.99-.33 2.16c0 .72.16 1.53.49 2.44c.33.9.71 1.62 1.21 2.15z\"/>","width":24,"height":24},
|
||||
@@ -186,7 +188,6 @@ export const BUNDLED_ICONS = {
|
||||
"mdi:code-json": {"body":"<path fill=\"currentColor\" d=\"M5 3h2v2H5v5a2 2 0 0 1-2 2a2 2 0 0 1 2 2v5h2v2H5c-1.07-.27-2-.9-2-2v-4a2 2 0 0 0-2-2H0v-2h1a2 2 0 0 0 2-2V5a2 2 0 0 1 2-2m14 0a2 2 0 0 1 2 2v4a2 2 0 0 0 2 2h1v2h-1a2 2 0 0 0-2 2v4a2 2 0 0 1-2 2h-2v-2h2v-5a2 2 0 0 1 2-2a2 2 0 0 1-2-2V5h-2V3zm-7 12a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1m-4 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1m8 0a1 1 0 0 1 1 1a1 1 0 0 1-1 1a1 1 0 0 1-1-1a1 1 0 0 1 1-1\"/>","width":24,"height":24},
|
||||
"mdi:code-tags": {"body":"<path fill=\"currentColor\" d=\"m14.6 16.6l4.6-4.6l-4.6-4.6L16 6l6 6l-6 6zm-5.2 0L4.8 12l4.6-4.6L8 6l-6 6l6 6z\"/>","width":24,"height":24},
|
||||
"mdi:cog": {"body":"<path fill=\"currentColor\" d=\"M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97s-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1s.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64z\"/>","width":24,"height":24},
|
||||
"mdi:crop-square": {"body":"<path fill=\"currentColor\" d=\"M18 18H6V6h12m0-2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2\"/>","width":24,"height":24},
|
||||
"mdi:database-refresh": {"body":"<path fill=\"currentColor\" d=\"M12 3c4.42 0 8 1.79 8 4s-3.58 4-8 4s-8-1.79-8-4s3.58-4 8-4M4 9c0 2.21 3.58 4 8 4c1.11 0 2.18-.11 3.14-.32c-.95.86-1.64 1.99-1.96 3.28L12 16c-4.42 0-8-1.79-8-4zm16 0v2h-.5l-.6.03c.7-.6 1.1-1.29 1.1-2.03M4 14c0 2.21 3.58 4 8 4l1-.03c.09 1.06.42 2.03.95 2.91L12 21c-4.42 0-8-1.79-8-4zm15-.5c1.11 0 2.11.45 2.83 1.17L23 13.5v4h-4l1.77-1.77A2.5 2.5 0 1 0 21 19h1.71A3.99 3.99 0 0 1 19 21.5c-2.21 0-4-1.79-4-4s1.79-4 4-4\"/>","width":24,"height":24},
|
||||
"mdi:dice-5": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m2 2a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m10 10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m0-10a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m-5 5a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2m-5 5a2 2 0 0 0-2 2a2 2 0 0 0 2 2a2 2 0 0 0 2-2a2 2 0 0 0-2-2\"/>","width":24,"height":24},
|
||||
"mdi:dog": {"body":"<path fill=\"currentColor\" d=\"M18 4c-1.71 0-2.75.33-3.35.61C13.88 4.23 13 4 12 4s-1.88.23-2.65.61C8.75 4.33 7.71 4 6 4c-3 0-5 8-5 10c0 .83 1.32 1.59 3.14 1.9c.64 2.24 3.66 3.95 7.36 4.1v-4.28c-.59-.37-1.5-1.04-1.5-1.72c0-1 2-1 2-1s2 0 2 1c0 .68-.91 1.35-1.5 1.72V20c3.7-.15 6.72-1.86 7.36-4.1C21.68 15.59 23 14.83 23 14c0-2-2-10-5-10M4.15 13.87c-.5-.12-.89-.26-1.15-.37c.25-2.77 2.2-7.1 3.05-7.5c.54 0 .95.06 1.32.11c-2.1 2.31-2.93 5.93-3.22 7.76M9 12a1 1 0 0 1-1-1c0-.54.45-1 1-1a1 1 0 0 1 1 1c0 .56-.45 1-1 1m6 0a1 1 0 0 1-1-1c0-.54.45-1 1-1a1 1 0 0 1 1 1c0 .56-.45 1-1 1m4.85 1.87c-.29-1.83-1.12-5.45-3.22-7.76c.37-.05.78-.11 1.32-.11c.85.4 2.8 4.73 3.05 7.5c-.25.11-.64.25-1.15.37\"/>","width":24,"height":24},
|
||||
@@ -203,13 +204,10 @@ export const BUNDLED_ICONS = {
|
||||
"mdi:flag-outline": {"body":"<path fill=\"currentColor\" d=\"m12.36 6l.4 2H18v6h-3.36l-.4-2H7V6zM14 4H5v17h2v-7h5.6l.4 2h7V6h-5.6\"/>","width":24,"height":24},
|
||||
"mdi:food-apple-outline": {"body":"<path fill=\"currentColor\" d=\"M20 10a5.27 5.27 0 0 0-7-2V3h-2v5a5.27 5.27 0 0 0-7 2c-2 3 3 12 5 12s2-1 3-1s1 1 3 1s7-9 5-12m-1.75 3.38c-.62 2.47-1.84 4.74-3.55 6.62c-.2 0-.43-.1-.6-.25a3.34 3.34 0 0 0-4.2 0c-.17.15-.4.25-.6.25a15.3 15.3 0 0 1-3.55-6.61c-.25-.73-.3-1.52-.09-2.27A3.37 3.37 0 0 1 8.5 9.4c.56.01 1.11.14 1.61.39l.89.45h2l.89-.45c.5-.25 1.05-.38 1.61-.39c1.18.03 2.26.68 2.84 1.71c.21.75.16 1.54-.09 2.27M11 5C5.38 8.07 4.11 3.78 4.11 3.78S6.77.19 11 5\"/>","width":24,"height":24},
|
||||
"mdi:format-align-center": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm4 4h10v2H7zm-4 4h18v2H3zm4 4h10v2H7zm-4 4h18v2H3z\"/>","width":24,"height":24},
|
||||
"mdi:format-align-justify": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm0 4h18v2H3zm0 4h18v2H3zm0 4h18v2H3zm0 4h18v2H3z\"/>","width":24,"height":24},
|
||||
"mdi:format-align-left": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm0 4h12v2H3zm0 4h18v2H3zm0 4h12v2H3zm0 4h18v2H3z\"/>","width":24,"height":24},
|
||||
"mdi:format-align-right": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm6 4h12v2H9zm-6 4h18v2H3zm6 4h12v2H9zm-6 4h18v2H3z\"/>","width":24,"height":24},
|
||||
"mdi:format-bold": {"body":"<path fill=\"currentColor\" d=\"M13.5 15.5H10v-3h3.5A1.5 1.5 0 0 1 15 14a1.5 1.5 0 0 1-1.5 1.5m-3.5-9h3A1.5 1.5 0 0 1 14.5 8A1.5 1.5 0 0 1 13 9.5h-3m5.6 1.29c.97-.68 1.65-1.79 1.65-2.79c0-2.26-1.75-4-4-4H7v14h7.04c2.1 0 3.71-1.7 3.71-3.79c0-1.52-.86-2.82-2.15-3.42\"/>","width":24,"height":24},
|
||||
"mdi:format-clear": {"body":"<path fill=\"currentColor\" d=\"M6 5v.18L8.82 8h2.4l-.72 1.68l2.1 2.1L14.21 8H20V5zM3.27 5L2 6.27l6.97 6.97L6.5 19h3l1.57-3.66L16.73 21L18 19.73L3.55 5.27z\"/>","width":24,"height":24},
|
||||
"mdi:format-color-highlight": {"body":"<path fill=\"currentColor\" d=\"m4 17l2.75-2.75l-.03-.02c-.58-.59-.58-1.54 0-2.12l4.74-4.74l4.24 4.24l-4.74 4.74c-.57.58-1.5.58-2.09.02l-.63.63zM15.91 2.91c.59-.58 1.54-.58 2.12 0l2.13 2.12c.58.59.58 1.54 0 2.13l-3.3 3.29l-4.24-4.24z\"/>","width":24,"height":24},
|
||||
"mdi:format-font": {"body":"<path fill=\"currentColor\" d=\"M17 8h3v12h1v1h-4v-1h1v-3h-4l-1.5 3H14v1h-4v-1h1zm1 1l-3.5 7H18zM5 3h5c1.11 0 2 .89 2 2v11H9v-5H6v5H3V5c0-1.11.89-2 2-2m1 2v4h3V5z\"/>","width":24,"height":24},
|
||||
"mdi:format-header-1": {"body":"<path fill=\"currentColor\" d=\"M3 4h2v6h4V4h2v14H9v-6H5v6H3zm11 14v-2h2V6.31l-2.5 1.44V5.44L16 4h2v12h2v2z\"/>","width":24,"height":24},
|
||||
"mdi:format-header-2": {"body":"<path fill=\"currentColor\" d=\"M3 4h2v6h4V4h2v14H9v-6H5v6H3zm18 14h-6a2 2 0 0 1-2-2c0-.53.2-1 .54-1.36l4.87-5.23c.37-.36.59-.86.59-1.41a2 2 0 0 0-2-2a2 2 0 0 0-2 2h-2a4 4 0 0 1 4-4a4 4 0 0 1 4 4c0 1.1-.45 2.1-1.17 2.83L15 16h6z\"/>","width":24,"height":24},
|
||||
"mdi:format-header-3": {"body":"<path fill=\"currentColor\" d=\"M3 4h2v6h4V4h2v14H9v-6H5v6H3zm12 0h4a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-4a2 2 0 0 1-2-2v-1h2v1h4v-4h-4v-2h4V6h-4v1h-2V6a2 2 0 0 1 2-2\"/>","width":24,"height":24},
|
||||
@@ -224,18 +222,16 @@ export const BUNDLED_ICONS = {
|
||||
"mdi:format-list-checks": {"body":"<path fill=\"currentColor\" d=\"M3 5h6v6H3zm2 2v2h2V7zm6 0h10v2H11zm0 8h10v2H11zm-6 5l-3.5-3.5l1.41-1.41L5 17.17l4.59-4.58L11 14z\"/>","width":24,"height":24},
|
||||
"mdi:format-list-group-plus": {"body":"<path fill=\"currentColor\" d=\"M17 14v3h-3v2h3v3h2v-3h3v-2h-3v-3m1-3v1.3c-.6-.2-1.3-.3-2-.3c-1.2 0-2.4.4-3.3 1H7v-2zm-7.9 6H7v-2h5.8c-.3.6-.6 1.3-.7 2M7 7h13v2H7zM5 19h2v2H3V3h4v2H5z\"/>","width":24,"height":24},
|
||||
"mdi:format-list-numbered": {"body":"<path fill=\"currentColor\" d=\"M7 13v-2h14v2zm0 6v-2h14v2zM7 7V5h14v2zM3 8V5H2V4h2v4zm-1 9v-1h3v4H2v-1h2v-.5H3v-1h1V17zm2.25-7a.75.75 0 0 1 .75.75c0 .2-.08.39-.21.52L3.12 13H5v1H2v-.92L4 11H2v-1z\"/>","width":24,"height":24},
|
||||
"mdi:format-page-break": {"body":"<path fill=\"currentColor\" d=\"M18 20H6v-2H4v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2h-2zM14 2H6a2 2 0 0 0-2 2v8h2V4h8v4h4v4h2V8zm-3 14H8v-2h3zm5 0h-3v-2h3zM3 14h3v2H3zm18 2h-3v-2h3z\"/>","width":24,"height":24},
|
||||
"mdi:format-paragraph": {"body":"<path fill=\"currentColor\" d=\"M13 4a4 4 0 0 1 4 4a4 4 0 0 1-4 4h-2v6H9V4zm0 6a2 2 0 0 0 2-2a2 2 0 0 0-2-2h-2v4z\"/>","width":24,"height":24},
|
||||
"mdi:format-quote-close": {"body":"<path fill=\"currentColor\" d=\"M14 17h3l2-4V7h-6v6h3M6 17h3l2-4V7H5v6h3z\"/>","width":24,"height":24},
|
||||
"mdi:format-quote-open": {"body":"<path fill=\"currentColor\" d=\"m10 7l-2 4h3v6H5v-6l2-4zm8 0l-2 4h3v6h-6v-6l2-4z\"/>","width":24,"height":24},
|
||||
"mdi:format-strikethrough": {"body":"<path fill=\"currentColor\" d=\"M3 14h18v-2H3m2-8v3h5v3h4V7h5V4m-9 15h4v-3h-4z\"/>","width":24,"height":24},
|
||||
"mdi:format-subscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zm5.85 13.62h-4.88v-1l.89-.8c.76-.65 1.32-1.19 1.7-1.63c.37-.44.56-.85.57-1.24a.9.9 0 0 0-.27-.7c-.18-.16-.47-.28-.86-.28c-.31 0-.58.06-.84.18l-.66.38l-.45-1.17c.27-.21.59-.39.98-.53s.82-.24 1.29-.24c.78.04 1.38.25 1.78.66s.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.47-.76.92-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24},
|
||||
"mdi:format-superscript": {"body":"<path fill=\"currentColor\" d=\"M16 7.41L11.41 12L16 16.59L14.59 18L10 13.41L5.41 18L4 16.59L8.59 12L4 7.41L5.41 6L10 10.59L14.59 6zM21.85 9h-4.88V8l.89-.82c.76-.64 1.32-1.18 1.7-1.63q.555-.66.57-1.23a.88.88 0 0 0-.27-.7c-.18-.19-.47-.28-.86-.29c-.31.01-.58.07-.84.17l-.66.39l-.45-1.17c.27-.22.59-.39.98-.53S18.85 2 19.32 2c.78 0 1.38.2 1.78.61c.4.39.62.93.62 1.57c-.01.56-.19 1.08-.54 1.55c-.34.48-.76.93-1.27 1.36l-.64.52v.02h2.58z\"/>","width":24,"height":24},
|
||||
"mdi:format-title": {"body":"<path fill=\"currentColor\" d=\"M5 4v3h5.5v12h3V7H19V4z\"/>","width":24,"height":24},
|
||||
"mdi:format-underline": {"body":"<path fill=\"currentColor\" d=\"M5 21h14v-2H5zm7-4a6 6 0 0 0 6-6V3h-2.5v8a3.5 3.5 0 0 1-3.5 3.5A3.5 3.5 0 0 1 8.5 11V3H6v8a6 6 0 0 0 6 6\"/>","width":24,"height":24},
|
||||
"mdi:hand-wave-outline": {"body":"<path fill=\"currentColor\" d=\"M7.03 4.95L3.5 8.5c-3.33 3.31-3.33 8.69 0 12s8.69 3.33 12 0l6-6c1-.97 1-2.56 0-3.54c-.1-.12-.23-.23-.37-.32l.37-.39c1-.97 1-2.56 0-3.54c-.14-.16-.33-.3-.5-.41c.38-.92.21-2.02-.54-2.77c-.87-.87-2.22-.96-3.2-.28a2.517 2.517 0 0 0-3.88-.42l-2.51 2.51c-.09-.14-.2-.27-.32-.39a2.53 2.53 0 0 0-3.52 0m1.41 1.42c.2-.2.51-.2.71 0s.2.51 0 .71l-3.18 3.18a3 3 0 0 1 0 4.24l1.41 1.41a5 5 0 0 0 1.12-5.36l6.3-6.3c.2-.2.51-.2.7 0s.21.51 0 .71l-4.59 4.6l1.41 1.41l6.01-6.01c.2-.2.51-.2.71 0s.2.51 0 .71l-6.01 6.01l1.41 1.41l4.95-4.95c.2-.2.51-.2.71 0s.2.51 0 .71l-5.66 5.65l1.41 1.42l3.54-3.54c.2-.2.51-.2.71 0s.2.51 0 .71l-6 6.01c-2.54 2.54-6.65 2.54-9.19 0s-2.54-6.65 0-9.19zM23 17c0 3.31-2.69 6-6 6v-1.5c2.5 0 4.5-2 4.5-4.5zM1 7c0-3.31 2.69-6 6-6v1.5c-2.5 0-4.5 2-4.5 4.5z\"/>","width":24,"height":24},
|
||||
"mdi:highlight-off": {"body":"<path fill=\"currentColor\" d=\"M12 20c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8m0-18C6.47 2 2 6.47 2 12s4.47 10 10 10s10-4.47 10-10S17.53 2 12 2m2.59 6L12 10.59L9.41 8L8 9.41L10.59 12L8 14.59L9.41 16L12 13.41L14.59 16L16 14.59L13.41 12L16 9.41z\"/>","width":24,"height":24},
|
||||
"mdi:home": {"body":"<path fill=\"currentColor\" d=\"M10 20v-6h4v6h5v-8h3L12 3L2 12h3v8z\"/>","width":24,"height":24},
|
||||
"mdi:image-plus": {"body":"<path fill=\"currentColor\" d=\"M18 15v3h-3v2h3v3h2v-3h3v-2h-3v-3zm-4.7 6H5c-1.1 0-2-.9-2-2V5c0-1.1.9-2 2-2h14c1.1 0 2 .9 2 2v8.3c-.6-.2-1.3-.3-2-.3c-1.1 0-2.2.3-3.1.9L14.5 12L11 16.5l-2.5-3L5 18h8.1c-.1.3-.1.7-.1 1c0 .7.1 1.4.3 2\"/>","width":24,"height":24},
|
||||
"mdi:image-plus-outline": {"body":"<path fill=\"currentColor\" d=\"M13 19c0 .7.13 1.37.35 2H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2v8.35c-.63-.22-1.3-.35-2-.35V5H5v14zm.96-6.71l-2.75 3.54l-1.96-2.36L6.5 17h6.85c.4-1.12 1.12-2.09 2.05-2.79zM20 18v-3h-2v3h-3v2h3v3h2v-3h3v-2z\"/>","width":24,"height":24},
|
||||
"mdi:image-sync-outline": {"body":"<path fill=\"currentColor\" d=\"M13.18 19c.17.72.46 1.39.85 2H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2v6.18c-.5-.11-1-.18-1.5-.18c-.17 0-.33 0-.5.03V5H5v14zm-1.97-3.17l-1.96-2.36L6.5 17h6.53c.11-1.46.7-2.78 1.61-3.81l-.68-.9zM19 13.5V12l-2.25 2.25L19 16.5V15a2.5 2.5 0 0 1 2.5 2.5c0 .4-.09.78-.26 1.12l1.09 1.09c.42-.63.67-1.39.67-2.21c0-2.21-1.79-4-4-4m0 6.5a2.5 2.5 0 0 1-2.5-2.5c0-.4.09-.78.26-1.12l-1.09-1.09c-.42.63-.67 1.39-.67 2.21c0 2.21 1.79 4 4 4V23l2.25-2.25L19 18.5z\"/>","width":24,"height":24},
|
||||
"mdi:import": {"body":"<path fill=\"currentColor\" d=\"m14 12l-4-4v3H2v2h8v3m10 2V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v3h2V6h12v12H6v-3H4v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2\"/>","width":24,"height":24},
|
||||
@@ -247,14 +243,10 @@ export const BUNDLED_ICONS = {
|
||||
"mdi:letter-b-box": {"body":"<path fill=\"currentColor\" d=\"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2m10 7.5V9a2 2 0 0 0-2-2H9v10h4a2 2 0 0 0 2-2v-1.5c0-.8-.7-1.5-1.5-1.5c.8 0 1.5-.7 1.5-1.5M13 15h-2v-2h2zm0-4h-2V9h2z\"/>","width":24,"height":24},
|
||||
"mdi:lightbulb-outline": {"body":"<path fill=\"currentColor\" d=\"M12 2a7 7 0 0 1 7 7c0 2.38-1.19 4.47-3 5.74V17a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1v-2.26C6.19 13.47 5 11.38 5 9a7 7 0 0 1 7-7M9 21v-1h6v1a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1m3-17a5 5 0 0 0-5 5c0 2.05 1.23 3.81 3 4.58V16h4v-2.42c1.77-.77 3-2.53 3-4.58a5 5 0 0 0-5-5\"/>","width":24,"height":24},
|
||||
"mdi:line-scan": {"body":"<path fill=\"currentColor\" d=\"M17 22v-2h3v-3h2v3.5c0 .4-.2.7-.5 1s-.7.5-1 .5zM7 22H3.5c-.4 0-.7-.2-1-.5s-.5-.7-.5-1V17h2v3h3zM17 2h3.5c.4 0 .7.2 1 .5s.5.6.5 1V7h-2V4h-3zM7 2v2H4v3H2V3.5c0-.4.2-.7.5-1s.6-.5 1-.5zm12 9H5v2h14z\"/>","width":24,"height":24},
|
||||
"mdi:link-variant": {"body":"<path fill=\"currentColor\" d=\"M10.59 13.41c.41.39.41 1.03 0 1.42c-.39.39-1.03.39-1.42 0a5.003 5.003 0 0 1 0-7.07l3.54-3.54a5.003 5.003 0 0 1 7.07 0a5.003 5.003 0 0 1 0 7.07l-1.49 1.49c.01-.82-.12-1.64-.4-2.42l.47-.48a2.98 2.98 0 0 0 0-4.24a2.98 2.98 0 0 0-4.24 0l-3.53 3.53a2.98 2.98 0 0 0 0 4.24m2.82-4.24c.39-.39 1.03-.39 1.42 0a5.003 5.003 0 0 1 0 7.07l-3.54 3.54a5.003 5.003 0 0 1-7.07 0a5.003 5.003 0 0 1 0-7.07l1.49-1.49c-.01.82.12 1.64.4 2.43l-.47.47a2.98 2.98 0 0 0 0 4.24a2.98 2.98 0 0 0 4.24 0l3.53-3.53a2.98 2.98 0 0 0 0-4.24a.973.973 0 0 1 0-1.42\"/>","width":24,"height":24},
|
||||
"mdi:link-variant-plus": {"body":"<path fill=\"currentColor\" d=\"M10.6 13.4a1 1 0 0 1-1.4 1.4a4.8 4.8 0 0 1 0-7l3.5-3.6a5.1 5.1 0 0 1 7.1 0a5.1 5.1 0 0 1 0 7.1l-1.5 1.5a6.4 6.4 0 0 0-.4-2.4l.5-.5a3.2 3.2 0 0 0 0-4.3a3.2 3.2 0 0 0-4.3 0l-3.5 3.6a2.9 2.9 0 0 0 0 4.2M23 18v2h-3v3h-2v-3h-3v-2h3v-3h2v3m-3.8-4.3a4.8 4.8 0 0 0-1.4-4.5a1 1 0 0 0-1.4 1.4a2.9 2.9 0 0 1 0 4.2l-3.5 3.6a3.2 3.2 0 0 1-4.3 0a3.2 3.2 0 0 1 0-4.3l.5-.4a7.3 7.3 0 0 1-.4-2.5l-1.5 1.5a5.1 5.1 0 0 0 0 7.1a5.1 5.1 0 0 0 7.1 0l1.8-1.8a6 6 0 0 1 3.1-4.3\"/>","width":24,"height":24},
|
||||
"mdi:logout": {"body":"<path fill=\"currentColor\" d=\"m17 7l-1.41 1.41L18.17 11H8v2h10.17l-2.58 2.58L17 17l5-5M4 5h8V3H4c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h8v-2H4z\"/>","width":24,"height":24},
|
||||
"mdi:marker": {"body":"<path fill=\"currentColor\" d=\"M18.5 1.15c-.53 0-1.04.19-1.43.58l-5.81 5.82l5.65 5.65l5.82-5.81c.77-.78.77-2.04 0-2.83l-2.84-2.83c-.39-.39-.89-.58-1.39-.58M10.3 8.5l-5.96 5.96c-.78.78-.78 2.04.02 2.85C3.14 18.54 1.9 19.77.67 21h5.66l.86-.86c.78.76 2.03.75 2.81-.02l5.95-5.96\"/>","width":24,"height":24},
|
||||
"mdi:marker-cancel": {"body":"<path fill=\"currentColor\" d=\"M17.5 13c2.5 0 4.5 2 4.5 4.5S20 22 17.5 22S13 20 13 17.5s2-4.5 4.5-4.5m0 1.5c-.56 0-1.08.15-1.5.42L20.08 19c.27-.42.42-.94.42-1.5a3 3 0 0 0-3-3m-3 3a3 3 0 0 0 3 3c.56 0 1.08-.15 1.5-.42L14.92 16c-.27.42-.42.94-.42 1.5m4-16.35c.5 0 1 .19 1.39.58l2.84 2.83c.77.79.77 2.05 0 2.83l-3.78 3.77a6.54 6.54 0 0 0-3.8.28l-3.89-3.89l5.81-5.82c.39-.39.9-.58 1.43-.58M10.3 8.5l3.59 3.6A6.49 6.49 0 0 0 11 17.5c0 .5.06 1 .16 1.45L10 20.12c-.78.77-2.03.78-2.81.02l-.86.86H.67l3.69-3.69c-.8-.81-.8-2.07-.02-2.85z\"/>","width":24,"height":24},
|
||||
"mdi:menu-down": {"body":"<path fill=\"currentColor\" d=\"m7 10l5 5l5-5z\"/>","width":24,"height":24},
|
||||
"mdi:message-alert": {"body":"<path fill=\"currentColor\" d=\"M13 11h-2V5h2m0 10h-2v-2h2m7-11H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2\"/>","width":24,"height":24},
|
||||
"mdi:minus": {"body":"<path fill=\"currentColor\" d=\"M19 13H5v-2h14z\"/>","width":24,"height":24},
|
||||
"mdi:near-me": {"body":"<path fill=\"currentColor\" d=\"M21 3L3 10.53v.97l6.84 2.66L12.5 21h.96z\"/>","width":24,"height":24},
|
||||
"mdi:palette": {"body":"<path fill=\"currentColor\" d=\"M17.5 12a1.5 1.5 0 0 1-1.5-1.5A1.5 1.5 0 0 1 17.5 9a1.5 1.5 0 0 1 1.5 1.5a1.5 1.5 0 0 1-1.5 1.5m-3-4A1.5 1.5 0 0 1 13 6.5A1.5 1.5 0 0 1 14.5 5A1.5 1.5 0 0 1 16 6.5A1.5 1.5 0 0 1 14.5 8m-5 0A1.5 1.5 0 0 1 8 6.5A1.5 1.5 0 0 1 9.5 5A1.5 1.5 0 0 1 11 6.5A1.5 1.5 0 0 1 9.5 8m-3 4A1.5 1.5 0 0 1 5 10.5A1.5 1.5 0 0 1 6.5 9A1.5 1.5 0 0 1 8 10.5A1.5 1.5 0 0 1 6.5 12M12 3a9 9 0 0 0-9 9a9 9 0 0 0 9 9a1.5 1.5 0 0 0 1.5-1.5c0-.39-.15-.74-.39-1c-.23-.27-.38-.62-.38-1a1.5 1.5 0 0 1 1.5-1.5H16a5 5 0 0 0 5-5c0-4.42-4.03-8-9-8\"/>","width":24,"height":24},
|
||||
"mdi:percent-outline": {"body":"<path fill=\"currentColor\" d=\"m18.5 3.5l2 2l-15 15l-2-2zM7 4c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3m10 10c1.66 0 3 1.34 3 3s-1.34 3-3 3s-3-1.34-3-3s1.34-3 3-3M7 6c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1m10 10c-.55 0-1 .45-1 1s.45 1 1 1s1-.45 1-1s-.45-1-1-1\"/>","width":24,"height":24},
|
||||
@@ -267,12 +259,11 @@ export const BUNDLED_ICONS = {
|
||||
"mdi:sort-numeric-descending-variant": {"body":"<path fill=\"currentColor\" d=\"M7.78 7c1.3.04 2.22.53 2.79 1.46c.56.94.84 2.1.82 3.49c.01 1.55-.3 2.78-.89 3.67c-.62.88-1.55 1.35-2.79 1.38c-1.26-.04-2.17-.5-2.75-1.44c-.58-.93-.87-2.11-.87-3.56s.3-2.64.91-3.56C5.59 7.5 6.5 7.04 7.78 7m-.03 1.63c-.44 0-.79.27-1.05.83c-.26.54-.38 1.41-.38 2.54c-.01 1.15.12 2 .37 2.54c.26.56.62.83 1.08.83c.92 0 1.39-1.13 1.4-3.37c0-2.23-.47-3.35-1.42-3.37M13.33 17v-1.78l.43.02l.54-.02l1.04-.19c.34-.11.66-.25.92-.45c.33-.23.6-.5.81-.82c.22-.31.37-.64.46-.98l-.03-.01c-.45.42-1.12.63-2.03.64c-.85-.01-1.56-.26-2.13-.76s-.84-1.22-.88-2.15c.01-1 .35-1.81 1.01-2.47c.67-.66 1.53-1 2.65-1.03c1.25.04 2.17.45 2.76 1.24c.59.76.88 1.76.88 2.95c-.01.96-.15 1.81-.44 2.57c-.29.74-.68 1.37-1.2 1.88c-.46.42-1.01.74-1.65.97q-.96.33-2.13.39zm2.73-8.37c-.41.01-.74.17-1 .48c-.25.31-.38.73-.38 1.25c0 .44.12.8.35 1.1c.24.31.6.46 1.08.47c.32 0 .59-.07.81-.19c.22-.13.38-.28.49-.46c.09-.11.12-.31.12-.57c.01-.55-.1-1.02-.33-1.43s-.61-.63-1.14-.65M15.75 19l-3.25 3.25L9.25 19z\"/>","width":24,"height":24},
|
||||
"mdi:star": {"body":"<path fill=\"currentColor\" d=\"M12 17.27L18.18 21l-1.64-7.03L22 9.24l-7.19-.62L12 2L9.19 8.62L2 9.24l5.45 4.73L5.82 21z\"/>","width":24,"height":24},
|
||||
"mdi:tab-plus": {"body":"<path fill=\"currentColor\" d=\"M3 3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h18a2 2 0 0 0 2-2V5a2 2 0 0 0-2-2zm0 2h10v4h8v10H3zm7 5v3H7v2h3v3h2v-3h3v-2h-3v-3z\"/>","width":24,"height":24},
|
||||
"mdi:table": {"body":"<path fill=\"currentColor\" d=\"M5 4h14a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2m0 4v4h6V8zm8 0v4h6V8zm-8 6v4h6v-4zm8 0v4h6v-4z\"/>","width":24,"height":24},
|
||||
"mdi:table-cog": {"body":"<path fill=\"currentColor\" d=\"M3 3h14a2 2 0 0 1 2 2v7.08a6 6 0 0 0-4.32.92H11v4h1.08c-.11.68-.11 1.35 0 2H3a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2m0 4v4h6V7zm8 0v4h6V7zm-8 6v4h6v-4zm19.78 6.32l-1.07-.82c.02-.17.04-.33.04-.5s-.01-.33-.04-.5l1.06-.82a.26.26 0 0 0 .06-.32l-1-1.73c-.06-.13-.19-.13-.33-.13l-1.22.5c-.28-.18-.54-.35-.85-.47l-.19-1.32c-.01-.12-.12-.21-.24-.21h-2c-.12 0-.23.09-.25.21l-.19 1.32c-.3.13-.59.29-.85.47l-1.24-.5c-.11 0-.24 0-.31.13l-1 1.73c-.06.11-.04.24.06.32l1.06.82a4.2 4.2 0 0 0 0 1l-1.06.82a.26.26 0 0 0-.06.32l1 1.73c.06.13.19.13.31.13l1.24-.5c.26.18.54.35.85.47l.19 1.32c.02.12.12.21.25.21h2c.12 0 .23-.09.25-.21l.19-1.32c.3-.13.56-.29.84-.47l1.22.5c.14 0 .27 0 .34-.13l1-1.73a.26.26 0 0 0-.06-.32M18 19.5c-.83 0-1.5-.67-1.5-1.5s.68-1.5 1.5-1.5s1.5.67 1.5 1.5s-.66 1.5-1.5 1.5\"/>","width":24,"height":24},
|
||||
"mdi:table-column": {"body":"<path fill=\"currentColor\" d=\"M8 2h8a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m0 8v4h8v-4zm0 6v4h8v-4zM8 4v4h8V4z\"/>","width":24,"height":24},
|
||||
"mdi:table-column-plus-after": {"body":"<path fill=\"currentColor\" d=\"M11 2a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H2V2zm-7 8v4h7v-4zm0 6v4h7v-4zM4 4v4h7V4zm11 7h3V8h2v3h3v2h-3v3h-2v-3h-3z\"/>","width":24,"height":24},
|
||||
"mdi:table-column-plus-before": {"body":"<path fill=\"currentColor\" d=\"M13 2a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h9V2zm7 8v4h-7v-4zm0 6v4h-7v-4zm0-12v4h-7V4zM9 11H6V8H4v3H1v2h3v3h2v-3h3z\"/>","width":24,"height":24},
|
||||
"mdi:table-column-remove": {"body":"<path fill=\"currentColor\" d=\"M4 2h7a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2m0 8v4h7v-4zm0 6v4h7v-4zM4 4v4h7V4zm13.59 8L15 9.41L16.41 8L19 10.59L21.59 8L23 9.41L20.41 12L23 14.59L21.59 16L19 13.41L16.41 16L15 14.59z\"/>","width":24,"height":24},
|
||||
"mdi:table-heart": {"body":"<path fill=\"currentColor\" d=\"M4 3h14a2 2 0 0 1 2 2v7.08a6 6 0 0 0-4.32.92H12v4h1.08c-.11.68-.11 1.35 0 2H4a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2m0 4v4h6V7zm8 0v4h6V7zm-8 6v4h6v-4zm15 9.3l-.6-.5c-2-1.9-3.4-3.1-3.4-4.6c0-1.2 1-2.2 2.2-2.2c.7 0 1.4.3 1.8.8c.4-.5 1.1-.8 1.8-.8c1.2 0 2.2.9 2.2 2.2c0 1.5-1.4 2.7-3.4 4.6z\"/>","width":24,"height":24},
|
||||
"mdi:table-large-plus": {"body":"<path fill=\"currentColor\" d=\"M12.35 20H10v-3h2.09c.12-.72.37-1.39.72-2H10v-3h4v1.54c.58-.54 1.25-.93 2-1.19V12h4v.35c.75.26 1.42.65 2 1.19V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v15c0 1.1.9 2 2 2h9.54c-.54-.58-.93-1.25-1.19-2M16 7h4v3h-4zm-6 0h4v3h-4zM8 20H4v-3h4zm0-5H4v-3h4zm0-5H4V7h4zm9 4h2v3h3v2h-3v3h-2v-3h-3v-2h3z\"/>","width":24,"height":24},
|
||||
"mdi:table-large-remove": {"body":"<path fill=\"currentColor\" d=\"M12.35 20H10v-3h2.09c.12-.72.37-1.39.72-2H10v-3h4v1.54c.58-.54 1.25-.93 2-1.19V12h4v.35c.75.26 1.42.65 2 1.19V5c0-1.1-.9-2-2-2H4c-1.1 0-2 .9-2 2v15c0 1.1.9 2 2 2h9.54c-.54-.58-.93-1.25-1.19-2M16 7h4v3h-4zm-6 0h4v3h-4zM8 20H4v-3h4zm0-5H4v-3h4zm0-5H4V7h4zm6.46 5.88l1.42-1.42L18 16.59l2.12-2.13l1.42 1.42L19.41 18l2.13 2.12l-1.42 1.42L18 19.41l-2.12 2.13l-1.42-1.42L16.59 18z\"/>","width":24,"height":24},
|
||||
"mdi:table-merge-cells": {"body":"<path fill=\"currentColor\" d=\"M5 10H3V4h8v2H5zm14 8h-6v2h8v-6h-2zM5 18v-4H3v6h8v-2zM21 4h-8v2h6v4h2zM8 13v2l3-3l-3-3v2H3v2zm8-2V9l-3 3l3 3v-2h5v-2z\"/>","width":24,"height":24},
|
||||
|
||||
+46
-19
@@ -1,23 +1,50 @@
|
||||
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'
|
||||
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'
|
||||
import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker'
|
||||
import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker'
|
||||
import TsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker'
|
||||
/*
|
||||
Addressed through the package's own `exports` map, which is `"./*": "./esm/vs/*.js"` — so the path
|
||||
inside it is what goes here and `monaco-editor/esm/vs/...` doubles the prefix into a file that does
|
||||
not exist. That is not a detail: it is the reason this module could never have run. It was written
|
||||
with the deeper specifiers, nothing imported it, and an import nobody makes is never resolved — so
|
||||
the build stayed green while the app went without workers entirely.
|
||||
*/
|
||||
import EditorWorker from 'monaco-editor/editor/editor.worker?worker'
|
||||
import JsonWorker from 'monaco-editor/language/json/json.worker?worker'
|
||||
import CssWorker from 'monaco-editor/language/css/css.worker?worker'
|
||||
import HtmlWorker from 'monaco-editor/language/html/html.worker?worker'
|
||||
import TsWorker from 'monaco-editor/language/typescript/ts.worker?worker'
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker (_, label) {
|
||||
if (label === 'json') {
|
||||
return new JsonWorker()
|
||||
/**
|
||||
* Where Monaco gets its web workers.
|
||||
*
|
||||
* Monaco does the expensive half of its work off the main thread — tokenizing, diffing, and every
|
||||
* language service behind completion, validation and formatting — and it has to be told how to make
|
||||
* one. `MonacoEnvironment.getWorker` is that: `standaloneWebWorkerService` asks for it first and only
|
||||
* falls back to working the location out for itself when it is absent.
|
||||
*
|
||||
* That fallback cannot work here. It resolves `editorWebWorkerMain.js` against the `import.meta.url`
|
||||
* of Monaco's own bundled chunk, which after a build is a path nothing was emitted at, and the module
|
||||
* that does load fails on the first relative import inside it —
|
||||
* `Failed to resolve module specifier "../../../base/common/worker/webWorkerBootstrap.js"`. Every
|
||||
* editor in the app raised it on open, and went without everything the workers provide.
|
||||
*
|
||||
* Each worker is a Vite `?worker` entry, so it is emitted as a script of its own and imported here as
|
||||
* a constructor. None of them is fetched until one is constructed, which is the first time an editor
|
||||
* is opened — so this costs a boot nothing on the pages that never open one.
|
||||
*/
|
||||
export function initializeMonaco() {
|
||||
self.MonacoEnvironment = {
|
||||
getWorker(_, label) {
|
||||
if (label === 'json') {
|
||||
return new JsonWorker()
|
||||
}
|
||||
if (label === 'css' || label === 'scss' || label === 'less') {
|
||||
return new CssWorker()
|
||||
}
|
||||
if (label === 'html' || label === 'handlebars' || label === 'razor') {
|
||||
return new HtmlWorker()
|
||||
}
|
||||
if (label === 'typescript' || label === 'javascript') {
|
||||
return new TsWorker()
|
||||
}
|
||||
return new EditorWorker()
|
||||
}
|
||||
if (label === 'css' || label === 'scss' || label === 'less') {
|
||||
return new CssWorker()
|
||||
}
|
||||
if (label === 'html' || label === 'handlebars' || label === 'razor') {
|
||||
return new HtmlWorker()
|
||||
}
|
||||
if (label === 'typescript' || label === 'javascript') {
|
||||
return new TsWorker()
|
||||
}
|
||||
return new EditorWorker()
|
||||
}
|
||||
}
|
||||
@@ -152,14 +152,19 @@ const state = reactive({
|
||||
// COMPUTED
|
||||
|
||||
/**
|
||||
* Blocks offered here: the ones this site has switched on, minus the ones the editor inserts itself.
|
||||
* Blocks offered here: the ones this site has switched on, minus the ones the editor inserts itself
|
||||
* and the ones that are not insertable on their own.
|
||||
*
|
||||
* A block that is off cannot render, so offering it is a trap. Tabs is on but has its own button in
|
||||
* the editor's side toolbar, which inserts the very same markup — listing it here as well is a second
|
||||
* way to the same place.
|
||||
* way to the same place. A CHILD block is listed by the API for its props alone — `block-tab` is a
|
||||
* panel of a tabset and means nothing on its own — so inserting one from here would only ever produce
|
||||
* something the page cannot draw.
|
||||
*/
|
||||
const blocks = computed(() =>
|
||||
state.blocks.filter((block) => block.isEnabled && !TOOLBAR_BLOCKS.includes(block.block))
|
||||
state.blocks.filter(
|
||||
(block) => block.isEnabled && !block.isChild && !TOOLBAR_BLOCKS.includes(block.block)
|
||||
)
|
||||
)
|
||||
|
||||
const markdown = computed(() => (state.selected ? blockMarkdown(state.selected, state.values) : ''))
|
||||
|
||||
@@ -4,10 +4,18 @@
|
||||
</div>
|
||||
<w-form v-else class="gap-4">
|
||||
<template v-for="field of fields" :key="field.name">
|
||||
<!--
|
||||
A block's choices are plain strings where the value is the wording, and `{ label, value }`
|
||||
objects where they differ -- a tab's header level reads "Heading 3" and is written as `3`. Only
|
||||
the second kind needs the value taken out of the option and resolved back to it for display,
|
||||
and asking for that on a list of strings would read `undefined` off each one.
|
||||
-->
|
||||
<w-select
|
||||
v-if="field.type === `select`"
|
||||
v-model="values[field.name]"
|
||||
:options="field.options ?? []"
|
||||
:emit-value="hasValueOptions(field)"
|
||||
:map-options="hasValueOptions(field)"
|
||||
outlined
|
||||
dense
|
||||
options-dense
|
||||
@@ -20,6 +28,47 @@
|
||||
v-model="values[field.name]"
|
||||
dense
|
||||
:label="field.label ?? field.name" />
|
||||
<!--
|
||||
An Iconify reference, with the picker on the end of the field and a preview of what is in it —
|
||||
the same control the page properties panel offers for a page's own icon, because it is the same
|
||||
question. The reference can still be typed, which is the quicker way when it is already known.
|
||||
-->
|
||||
<w-input
|
||||
v-else-if="field.type === `icon`"
|
||||
v-model="values[field.name]"
|
||||
outlined
|
||||
dense
|
||||
:label="field.label ?? field.name"
|
||||
:aria-label="field.label ?? field.name"
|
||||
:required="field.required"
|
||||
:hint="field.hint">
|
||||
<template #prepend>
|
||||
<w-icon
|
||||
v-if="values[field.name]"
|
||||
:name="values[field.name]"
|
||||
size="20px"
|
||||
color="primary" />
|
||||
</template>
|
||||
<template #append>
|
||||
<!--
|
||||
A button rather than a bare `w-icon`: for a bundled icon WIcon renders an `<svg>` whose
|
||||
body is set through `v-html`, which renders no slot — so a menu inside one never exists.
|
||||
The same note as in `PagePropertiesDialog`, where that was found the hard way.
|
||||
-->
|
||||
<w-btn
|
||||
flat
|
||||
dense
|
||||
round
|
||||
icon="la:icons"
|
||||
color="primary"
|
||||
:aria-label="t(`iconPicker.open`)">
|
||||
<w-tooltip>{{ t('iconPicker.open') }}</w-tooltip>
|
||||
<w-menu content-class="shadow-7" anchor="bottom right" self="top right">
|
||||
<icon-picker-dialog v-model="values[field.name]" />
|
||||
</w-menu>
|
||||
</w-btn>
|
||||
</template>
|
||||
</w-input>
|
||||
<w-input
|
||||
v-else
|
||||
v-model="values[field.name]"
|
||||
@@ -37,6 +86,8 @@
|
||||
<script setup>
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import IconPickerDialog from '@/components/IconPickerDialog.vue'
|
||||
|
||||
/**
|
||||
* The form a block's props make: one field per prop, in the order the block declares them.
|
||||
*
|
||||
@@ -72,4 +123,9 @@ defineProps({
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** Whether this field's choices are `{ label, value }` objects rather than plain strings. */
|
||||
function hasValueOptions(field) {
|
||||
return (field.options ?? []).some((option) => option !== null && typeof option === 'object')
|
||||
}
|
||||
</script>
|
||||
@@ -408,6 +408,12 @@ const collabEnabled = computed(
|
||||
|
||||
let editor
|
||||
let md
|
||||
/**
|
||||
* The collaboration watchers, which have to be stopped by hand — see where they are created.
|
||||
*
|
||||
* @type {Array<() => void>}
|
||||
*/
|
||||
const collabWatchers = []
|
||||
/** Where the paste listener ended up, so it can be taken off the same node. See the note in onMounted. */
|
||||
let pasteCaptureNode = null
|
||||
/** The "Edit Table" lens provider, which is registered against the language rather than this editor. */
|
||||
@@ -1805,38 +1811,48 @@ onMounted(async () => {
|
||||
editor.updateOptions({ readOnly: true })
|
||||
startCollabSession({ siteId: siteStore.id, pageId: pageStore.id })
|
||||
|
||||
watch(
|
||||
() => collabStore.status,
|
||||
(status) => {
|
||||
if (status === 'connected') {
|
||||
bindCollabEditor(editor)
|
||||
}
|
||||
if (status !== 'connecting') {
|
||||
editor.updateOptions({ readOnly: false })
|
||||
}
|
||||
if (status === 'denied') {
|
||||
notify({
|
||||
type: 'warning',
|
||||
message: t('editor.collab.notAllowed')
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
/*
|
||||
Somebody else saved the page. The editor state has already been put back to "nothing pending" by
|
||||
the session -- this is only so that the author is told why their Save button went quiet.
|
||||
Both handles are kept, and both are stopped by hand on the way out.
|
||||
|
||||
A watcher created during `setup` belongs to the component and stops with it. These do not: this
|
||||
hook is `async`, and everything after its first `await` runs with no component instance current
|
||||
— so Vue has nothing to attach them to and they outlive the editor they were written for. Left
|
||||
running they fire on the NEXT session's connection and reach for a Monaco editor that has been
|
||||
disposed, and one more pair is added every time an editor is opened.
|
||||
*/
|
||||
watch(
|
||||
() => collabStore.lastSave,
|
||||
(lastSave) => {
|
||||
if (lastSave && lastSave.authorId !== userStore.id) {
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('editor.collab.savedBy', { name: lastSave.authorName })
|
||||
})
|
||||
collabWatchers.push(
|
||||
watch(
|
||||
() => collabStore.status,
|
||||
(status) => {
|
||||
if (status === 'connected') {
|
||||
bindCollabEditor(editor)
|
||||
}
|
||||
if (status !== 'connecting') {
|
||||
editor.updateOptions({ readOnly: false })
|
||||
}
|
||||
if (status === 'denied') {
|
||||
notify({
|
||||
type: 'warning',
|
||||
message: t('editor.collab.notAllowed')
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
/*
|
||||
Somebody else saved the page. The editor state has already been put back to "nothing pending"
|
||||
by the session -- this is only so that the author is told why their Save button went quiet.
|
||||
*/
|
||||
watch(
|
||||
() => collabStore.lastSave,
|
||||
(lastSave) => {
|
||||
if (lastSave && lastSave.authorId !== userStore.id) {
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('editor.collab.savedBy', { name: lastSave.authorName })
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1856,6 +1872,11 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
// -> First, because everything below is what they reach for: these are not stopped by the component
|
||||
// going away, and one that fires afterwards finds a disposed editor
|
||||
for (const stop of collabWatchers.splice(0)) {
|
||||
stop()
|
||||
}
|
||||
EVENT_BUS.off('insertAsset', insertAssetClb)
|
||||
EVENT_BUS.off('insertTable', insertTableClb)
|
||||
EVENT_BUS.off('insertBlock', insertBlockClb)
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,899 +0,0 @@
|
||||
<template>
|
||||
<div class="wysiwyg-container">
|
||||
<div class="wysiwyg-toolbar" v-if="editor">
|
||||
<template v-for="menuItem of menuBar">
|
||||
<w-separator class="mx-1" v-if="menuItem.type === `divider`" vertical />
|
||||
<w-btn
|
||||
v-else-if="menuItem.type === `dropdown`"
|
||||
:key="`ddn-` + menuItem.key"
|
||||
flat
|
||||
:icon="menuItem.icon"
|
||||
padding="xs"
|
||||
:class='{ "is-active": menuItem.isActive && menuItem.isActive() }'
|
||||
:color="menuItem.isActive && menuItem.isActive() ? `primary` : `grey-10`"
|
||||
:aria-label="menuItem.title"
|
||||
split
|
||||
:disabled="menuItem.disabled && menuItem.disabled()">
|
||||
<w-menu>
|
||||
<w-list dense padding>
|
||||
<template v-for="child of menuItem.children">
|
||||
<w-separator class="my-2" v-if="child.type === `divider`" />
|
||||
<w-item
|
||||
v-else
|
||||
:key="child.key"
|
||||
clickable
|
||||
@click="child.action"
|
||||
:active="child.isActive && child.isActive()"
|
||||
active-class="text-primary"
|
||||
:disabled="child.disabled && child.disabled()">
|
||||
<w-item-section side>
|
||||
<w-icon :name="child.icon" :color="child.color" />
|
||||
</w-item-section>
|
||||
<w-item-section><w-item-label>{{child.title}}</w-item-label></w-item-section>
|
||||
</w-item>
|
||||
</template>
|
||||
</w-list>
|
||||
</w-menu>
|
||||
</w-btn>
|
||||
<w-btn-group v-else-if="menuItem.type === `btngroup`" :key="`btngrp-` + menuItem.key" flat>
|
||||
<w-btn
|
||||
v-for="child of menuItem.children"
|
||||
:key="child.key"
|
||||
flat
|
||||
:icon="child.icon"
|
||||
padding="xs"
|
||||
:class='{ "is-active": child.isActive && child.isActive() }'
|
||||
:color="child.isActive && child.isActive() ? `primary` : `grey-10`"
|
||||
@click="child.action"
|
||||
:aria-label="child.title"
|
||||
:disabled="menuItem.disabled && menuItem.disabled()" />
|
||||
</w-btn-group>
|
||||
<w-btn
|
||||
v-else
|
||||
:key="`btn-` + menuItem.key"
|
||||
flat
|
||||
:icon="menuItem.icon"
|
||||
padding="xs"
|
||||
:class='{ "is-active": menuItem.isActive && menuItem.isActive() }'
|
||||
:color="menuItem.isActive && menuItem.isActive() ? `primary` : `grey-10`"
|
||||
@click="menuItem.action"
|
||||
:aria-label="menuItem.title"
|
||||
:disabled="menuItem.disabled && menuItem.disabled()" />
|
||||
</template>
|
||||
<!-- q-space -->
|
||||
<!-- q-btn( -->
|
||||
<!-- size='sm' -->
|
||||
<!-- unelevated -->
|
||||
<!-- color='red' -->
|
||||
<!-- label='Test' -->
|
||||
<!-- @click='snapshot' -->
|
||||
<!-- ) -->
|
||||
</div>
|
||||
<!-- q-scroll-area( -->
|
||||
<!-- :thumb-style='thumbStyle' -->
|
||||
<!-- :bar-style='barStyle' -->
|
||||
<!-- style='height: 100%;' -->
|
||||
<!-- ) -->
|
||||
<editor-content :editor="editor" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, reactive, shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { usePageStore } from '@/stores/page'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
import { useEditor, EditorContent } from '@tiptap/vue-3'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
||||
import { Color } from '@tiptap/extension-color'
|
||||
import FontFamily from '@tiptap/extension-font-family'
|
||||
import Highlight from '@tiptap/extension-highlight'
|
||||
import Image from '@tiptap/extension-image'
|
||||
import Mention from '@tiptap/extension-mention'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import Table from '@tiptap/extension-table'
|
||||
import TableRow from '@tiptap/extension-table-row'
|
||||
import TableCell from '@tiptap/extension-table-cell'
|
||||
import TableHeader from '@tiptap/extension-table-header'
|
||||
import TaskList from '@tiptap/extension-task-list'
|
||||
import TaskItem from '@tiptap/extension-task-item'
|
||||
import TextAlign from '@tiptap/extension-text-align'
|
||||
import TextStyle from '@tiptap/extension-text-style'
|
||||
import Typography from '@tiptap/extension-typography'
|
||||
import { common, createLowlight } from 'lowlight'
|
||||
|
||||
const lowlight = createLowlight(common)
|
||||
|
||||
|
||||
// STORES
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const pageStore = usePageStore()
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// STATE
|
||||
|
||||
const state = reactive({
|
||||
// editor: null,
|
||||
ydoc: null
|
||||
})
|
||||
|
||||
let editor = null
|
||||
|
||||
const thumbStyle = {
|
||||
right: '2px',
|
||||
borderRadius: '5px',
|
||||
backgroundColor: '#000',
|
||||
width: '5px',
|
||||
opacity: 0.15
|
||||
}
|
||||
const barStyle = {
|
||||
backgroundColor: '#FAFAFA',
|
||||
width: '9px',
|
||||
opacity: 1
|
||||
}
|
||||
const menuBar = [
|
||||
{
|
||||
key: 'bold',
|
||||
icon: 'mdi:format-bold',
|
||||
title: 'Bold',
|
||||
action: () => editor.value.chain().focus().toggleBold().run(),
|
||||
isActive: () => editor.value.isActive('bold')
|
||||
},
|
||||
{
|
||||
key: 'italic',
|
||||
icon: 'mdi:format-italic',
|
||||
title: 'Italic',
|
||||
action: () => editor.value.chain().focus().toggleItalic().run(),
|
||||
isActive: () => editor.value.isActive('italic')
|
||||
},
|
||||
{
|
||||
key: 'strikethrough',
|
||||
icon: 'mdi:format-strikethrough',
|
||||
title: 'Strike',
|
||||
action: () => editor.value.chain().focus().toggleStrike().run(),
|
||||
isActive: () => editor.value.isActive('strike')
|
||||
},
|
||||
{
|
||||
key: 'code',
|
||||
icon: 'mdi:code-tags',
|
||||
title: 'Code',
|
||||
action: () => editor.value.chain().focus().toggleCode().run(),
|
||||
isActive: () => editor.value.isActive('code')
|
||||
},
|
||||
{
|
||||
key: 'fontfamily',
|
||||
icon: 'mdi:format-font',
|
||||
title: 'Font Family',
|
||||
type: 'dropdown',
|
||||
isActive: () => editor.value.isActive('fontFamily'),
|
||||
children: [
|
||||
{
|
||||
key: 'fontunset',
|
||||
icon: 'mdi:format-font',
|
||||
title: 'Sans-Serif',
|
||||
action: () => editor.value.chain().focus().unsetFontFamily().run()
|
||||
},
|
||||
{
|
||||
key: 'monospace',
|
||||
icon: 'mdi:format-font',
|
||||
title: 'Monospace',
|
||||
action: () => editor.value.chain().focus().setFontFamily('monospace').run()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'color',
|
||||
icon: 'mdi:palette',
|
||||
title: 'Text Color',
|
||||
type: 'dropdown',
|
||||
isActive: () => editor.value.isActive('color'),
|
||||
children: [
|
||||
{
|
||||
key: 'color-blue',
|
||||
icon: 'mdi:palette',
|
||||
title: 'Blue',
|
||||
color: 'blue',
|
||||
action: () => editor.value.chain().focus().toggleHighlight().run()
|
||||
},
|
||||
{
|
||||
key: 'color-brown',
|
||||
icon: 'mdi:palette',
|
||||
title: 'Brown',
|
||||
color: 'brown',
|
||||
action: () => editor.value.chain().focus().toggleHighlight().run()
|
||||
},
|
||||
{
|
||||
key: 'color-green',
|
||||
icon: 'mdi:palette',
|
||||
title: 'Green',
|
||||
color: 'green',
|
||||
action: () => editor.value.chain().focus().toggleHighlight().run()
|
||||
},
|
||||
{
|
||||
key: 'color-orange',
|
||||
icon: 'mdi:palette',
|
||||
title: 'Orange',
|
||||
color: 'orange',
|
||||
action: () => editor.value.chain().focus().toggleHighlight().run()
|
||||
},
|
||||
{
|
||||
key: 'color-pink',
|
||||
icon: 'mdi:palette',
|
||||
title: 'Pink',
|
||||
color: 'pink',
|
||||
action: () => editor.value.chain().focus().toggleHighlight().run()
|
||||
},
|
||||
{
|
||||
key: 'color-purple',
|
||||
icon: 'mdi:palette',
|
||||
title: 'Purple',
|
||||
color: 'purple',
|
||||
action: () => editor.value.chain().focus().toggleHighlight().run()
|
||||
},
|
||||
{
|
||||
key: 'color-red',
|
||||
icon: 'mdi:palette',
|
||||
title: 'Red',
|
||||
color: 'red',
|
||||
action: () => editor.value.chain().focus().toggleHighlight().run()
|
||||
},
|
||||
{
|
||||
key: 'color-teal',
|
||||
icon: 'mdi:palette',
|
||||
title: 'Teal',
|
||||
color: 'teal',
|
||||
action: () => editor.value.chain().focus().toggleHighlight().run()
|
||||
},
|
||||
{
|
||||
key: 'color-yellow',
|
||||
icon: 'mdi:palette',
|
||||
title: 'Yellow',
|
||||
color: 'yellow',
|
||||
action: () => editor.value.chain().focus().toggleHighlight().run()
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'color-remove',
|
||||
icon: 'mdi:palette',
|
||||
title: 'Default',
|
||||
color: 'grey',
|
||||
action: () => editor.value.chain().focus().unsetHighlight().run()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'highlight',
|
||||
icon: 'mdi:marker',
|
||||
title: 'Highlight',
|
||||
type: 'dropdown',
|
||||
isActive: () => editor.value.isActive('highlight'),
|
||||
children: [
|
||||
{
|
||||
key: 'highlight-yellow',
|
||||
icon: 'mdi:marker',
|
||||
title: 'Yellow',
|
||||
color: 'yellow',
|
||||
action: () => editor.value.chain().focus().toggleHighlight().run()
|
||||
},
|
||||
{
|
||||
key: 'highlight-blue',
|
||||
icon: 'mdi:marker',
|
||||
title: 'Blue',
|
||||
color: 'blue',
|
||||
action: () => editor.value.chain().focus().toggleHighlight().run()
|
||||
},
|
||||
{
|
||||
key: 'highlight-pink',
|
||||
icon: 'mdi:marker',
|
||||
title: 'Pink',
|
||||
color: 'pink',
|
||||
action: () => editor.value.chain().focus().toggleHighlight().run()
|
||||
},
|
||||
{
|
||||
key: 'highlight-green',
|
||||
icon: 'mdi:marker',
|
||||
title: 'Green',
|
||||
color: 'green',
|
||||
action: () => editor.value.chain().focus().toggleHighlight().run()
|
||||
},
|
||||
{
|
||||
key: 'highlight-orange',
|
||||
icon: 'mdi:marker',
|
||||
title: 'Orange',
|
||||
color: 'orange',
|
||||
action: () => editor.value.chain().focus().toggleHighlight().run()
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'highlight-remove',
|
||||
icon: 'mdi:marker-cancel',
|
||||
title: 'Remove',
|
||||
color: 'grey',
|
||||
action: () => editor.value.chain().focus().unsetHighlight().run()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'header',
|
||||
icon: 'mdi:format-header-pound',
|
||||
title: 'Header',
|
||||
type: 'dropdown',
|
||||
isActive: () => editor.value.isActive('heading'),
|
||||
children: [
|
||||
{
|
||||
key: 'h1',
|
||||
icon: 'mdi:format-header-1',
|
||||
title: 'Header 1',
|
||||
action: () => editor.value.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||
isActive: () => editor.value.isActive('heading', { level: 1 })
|
||||
},
|
||||
{
|
||||
key: 'h2',
|
||||
icon: 'mdi:format-header-2',
|
||||
title: 'Header 2',
|
||||
action: () => editor.value.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
isActive: () => editor.value.isActive('heading', { level: 2 })
|
||||
},
|
||||
{
|
||||
key: 'h3',
|
||||
icon: 'mdi:format-header-3',
|
||||
title: 'Header 3',
|
||||
action: () => editor.value.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
isActive: () => editor.value.isActive('heading', { level: 3 })
|
||||
},
|
||||
{
|
||||
key: 'h4',
|
||||
icon: 'mdi:format-header-4',
|
||||
title: 'Header 4',
|
||||
action: () => editor.value.chain().focus().toggleHeading({ level: 4 }).run(),
|
||||
isActive: () => editor.value.isActive('heading', { level: 4 })
|
||||
},
|
||||
{
|
||||
key: 'h5',
|
||||
icon: 'mdi:format-header-5',
|
||||
title: 'Header 5',
|
||||
action: () => editor.value.chain().focus().toggleHeading({ level: 5 }).run(),
|
||||
isActive: () => editor.value.isActive('heading', { level: 5 })
|
||||
},
|
||||
{
|
||||
key: 'h6',
|
||||
icon: 'mdi:format-header-6',
|
||||
title: 'Header 6',
|
||||
action: () => editor.value.chain().focus().toggleHeading({ level: 6 }).run(),
|
||||
isActive: () => editor.value.isActive('heading', { level: 6 })
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'paragraph',
|
||||
icon: 'mdi:format-paragraph',
|
||||
title: 'Paragraph',
|
||||
action: () => editor.value.chain().focus().setParagraph().run(),
|
||||
isActive: () => editor.value.isActive('paragraph')
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'align',
|
||||
type: 'btngroup',
|
||||
children: [
|
||||
{
|
||||
key: 'align-left',
|
||||
icon: 'mdi:format-align-left',
|
||||
title: 'Left Align',
|
||||
action: () => editor.value.chain().focus().setTextAlign('left').run(),
|
||||
isActive: () => editor.value.isActive({ textAlign: 'left' })
|
||||
},
|
||||
{
|
||||
key: 'align-center',
|
||||
icon: 'mdi:format-align-center',
|
||||
title: 'Center Align',
|
||||
action: () => editor.value.chain().focus().setTextAlign('center').run(),
|
||||
isActive: () => editor.value.isActive({ textAlign: 'center' })
|
||||
},
|
||||
{
|
||||
key: 'align-right',
|
||||
icon: 'mdi:format-align-right',
|
||||
title: 'Right Align',
|
||||
action: () => editor.value.chain().focus().setTextAlign('right').run(),
|
||||
isActive: () => editor.value.isActive({ textAlign: 'right' })
|
||||
},
|
||||
{
|
||||
key: 'align-justify',
|
||||
icon: 'mdi:format-align-justify',
|
||||
title: 'Justify Align',
|
||||
action: () => editor.value.chain().focus().setTextAlign('justify').run(),
|
||||
isActive: () => editor.value.isActive({ textAlign: 'justify' })
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'bulletlist',
|
||||
icon: 'mdi:format-list-bulleted',
|
||||
title: 'Bullet List',
|
||||
action: () => editor.value.chain().focus().toggleBulletList().run(),
|
||||
isActive: () => editor.value.isActive('bulletList')
|
||||
},
|
||||
{
|
||||
key: 'orderedlist',
|
||||
icon: 'mdi:format-list-numbered',
|
||||
title: 'Ordered List',
|
||||
action: () => editor.value.chain().focus().toggleOrderedList().run(),
|
||||
isActive: () => editor.value.isActive('orderedList')
|
||||
},
|
||||
{
|
||||
key: 'tasklist',
|
||||
icon: 'mdi:format-list-checks',
|
||||
title: 'Task List',
|
||||
action: () => editor.value.chain().focus().toggleTaskList().run(),
|
||||
isActive: () => editor.value.isActive('taskList')
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'codeblock',
|
||||
icon: 'mdi:code-json',
|
||||
title: 'Code Block',
|
||||
action: () => editor.value.chain().focus().toggleCodeBlock().run(),
|
||||
isActive: () => editor.value.isActive('codeBlock')
|
||||
},
|
||||
{
|
||||
key: 'blockquote',
|
||||
icon: 'mdi:format-quote-open',
|
||||
title: 'Blockquote',
|
||||
action: () => editor.value.chain().focus().toggleBlockquote().run(),
|
||||
isActive: () => editor.value.isActive('blockquote')
|
||||
},
|
||||
{
|
||||
key: 'rule',
|
||||
icon: 'mdi:minus',
|
||||
title: 'Horizontal Rule',
|
||||
action: () => editor.value.chain().focus().setHorizontalRule().run()
|
||||
},
|
||||
{
|
||||
key: 'link',
|
||||
icon: 'mdi:link-variant',
|
||||
title: 'Link',
|
||||
action: () => {
|
||||
// TODO: insert link
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'image',
|
||||
icon: 'mdi:image-plus',
|
||||
title: 'Image',
|
||||
action: () => {
|
||||
siteStore.openFileManager({ insertMode: true })
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'table',
|
||||
icon: 'mdi:table',
|
||||
title: 'Table',
|
||||
type: 'dropdown',
|
||||
isActive: () => editor.value.isActive('table'),
|
||||
children: [
|
||||
{
|
||||
key: 'table-insert',
|
||||
icon: 'mdi:table-large-plus',
|
||||
title: 'Insert Table',
|
||||
action: () =>
|
||||
editor.value.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'table-addcolumnbefore',
|
||||
icon: 'mdi:table-column-plus-before',
|
||||
title: 'Add Column Before',
|
||||
action: () => editor.value.chain().focus().addColumnBefore().run(),
|
||||
disabled: () => !editor.value.can().addColumnBefore()
|
||||
},
|
||||
{
|
||||
key: 'table-addcolumnafter',
|
||||
icon: 'mdi:table-column-plus-after',
|
||||
title: 'Add Column After',
|
||||
action: () => editor.value.chain().focus().addColumnAfter().run(),
|
||||
disabled: () => !editor.value.can().addColumnAfter()
|
||||
},
|
||||
{
|
||||
key: 'table-deletecolumn',
|
||||
icon: 'mdi:table-column-remove',
|
||||
title: 'Remove Column',
|
||||
action: () => editor.value.chain().focus().deleteColumn().run(),
|
||||
disabled: () => !editor.value.can().deleteColumn()
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'table-addrowbefore',
|
||||
icon: 'mdi:table-row-plus-before',
|
||||
title: 'Add Row Before',
|
||||
action: () => editor.value.chain().focus().addRowBefore().run(),
|
||||
disabled: () => !editor.value.can().addRowBefore()
|
||||
},
|
||||
{
|
||||
key: 'table-addrowafter',
|
||||
icon: 'mdi:table-row-plus-after',
|
||||
title: 'Add Row After',
|
||||
action: () => editor.value.chain().focus().addRowAfter().run(),
|
||||
disabled: () => !editor.value.can().addRowAfter()
|
||||
},
|
||||
{
|
||||
key: 'table-deleterow',
|
||||
icon: 'mdi:table-row-remove',
|
||||
title: 'Remove Row',
|
||||
action: () => editor.value.chain().focus().deleteRow().run(),
|
||||
disabled: () => !editor.value.can().deleteRow()
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'table-merge',
|
||||
icon: 'mdi:table-merge-cells',
|
||||
title: 'Merge Cells',
|
||||
action: () => editor.value.chain().focus().mergeCells().run(),
|
||||
disabled: () => !editor.value.can().mergeCells()
|
||||
},
|
||||
{
|
||||
key: 'table-split',
|
||||
icon: 'mdi:table-split-cell',
|
||||
title: 'Split Cell',
|
||||
action: () => editor.value.chain().focus().splitCell().run(),
|
||||
disabled: () => !editor.value.can().splitCell()
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'table-toggleHeaderColumn',
|
||||
icon: 'mdi:table-column',
|
||||
title: 'Toggle Header Column',
|
||||
action: () => editor.value.chain().focus().toggleHeaderColumn().run(),
|
||||
disabled: () => !editor.value.can().toggleHeaderColumn()
|
||||
},
|
||||
{
|
||||
key: 'table-toggleHeaderRow',
|
||||
icon: 'mdi:table-row',
|
||||
title: 'Toggle Header Row',
|
||||
action: () => editor.value.chain().focus().toggleHeaderRow().run(),
|
||||
disabled: () => !editor.value.can().toggleHeaderRow()
|
||||
},
|
||||
{
|
||||
key: 'table-toggleHeaderCell',
|
||||
icon: 'mdi:crop-square',
|
||||
title: 'Toggle Header Cell',
|
||||
action: () => editor.value.chain().focus().toggleHeaderCell().run(),
|
||||
disabled: () => !editor.value.can().toggleHeaderCell()
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'table-fix',
|
||||
icon: 'mdi:table-heart',
|
||||
title: 'Fix Table',
|
||||
action: () => editor.value.chain().focus().fixTables().run(),
|
||||
disabled: () => !editor.value.can().fixTables()
|
||||
},
|
||||
{
|
||||
key: 'table-remove',
|
||||
icon: 'mdi:table-large-remove',
|
||||
title: 'Delete Table',
|
||||
action: () => editor.value.chain().focus().deleteTable().run(),
|
||||
disabled: () => !editor.value.can().deleteTable()
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'pagebreak',
|
||||
icon: 'mdi:format-page-break',
|
||||
title: 'Hard Break',
|
||||
action: () => editor.value.chain().focus().setHardBreak().run()
|
||||
},
|
||||
{
|
||||
key: 'clearformat',
|
||||
icon: 'mdi:format-clear',
|
||||
title: 'Clear Format',
|
||||
action: () => editor.value.chain().focus().clearNodes().unsetAllMarks().run()
|
||||
},
|
||||
{
|
||||
type: 'divider'
|
||||
},
|
||||
{
|
||||
key: 'undo',
|
||||
icon: 'mdi:undo-variant',
|
||||
title: 'Undo',
|
||||
action: () => editor.value.chain().focus().undo().run(),
|
||||
disabled: () => !editor.value.can().undo()
|
||||
},
|
||||
{
|
||||
key: 'redo',
|
||||
icon: 'mdi:redo-variant',
|
||||
title: 'Redo',
|
||||
action: () => editor.value.chain().focus().redo().run(),
|
||||
disabled: () => !editor.value.can().redo()
|
||||
}
|
||||
]
|
||||
|
||||
// METHODS
|
||||
|
||||
function init() {
|
||||
// -> Setup Editor View
|
||||
editorStore.$patch({
|
||||
hideSideNav: false
|
||||
})
|
||||
|
||||
// -> Init Live Collab
|
||||
// this.ydoc = new Y.Doc()
|
||||
|
||||
/* eslint-disable no-unused-vars */
|
||||
// const dbProvider = new IndexeddbPersistence('example-document', this.ydoc)
|
||||
// const wsProvider = new WebsocketProvider('ws://127.0.0.1:1234', 'example-document', this.ydoc)
|
||||
/* eslint-enable no-unused-vars */
|
||||
|
||||
// -> Initialize TipTap
|
||||
editor = useEditor({
|
||||
content:
|
||||
pageStore.content && pageStore.content.startsWith('{')
|
||||
? JSON.parse(pageStore.content)
|
||||
: `<p>${pageStore.content}</p>`,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
codeBlock: false,
|
||||
history: {
|
||||
depth: 500
|
||||
}
|
||||
}),
|
||||
CodeBlockLowlight.configure({
|
||||
lowlight
|
||||
}),
|
||||
Color,
|
||||
// Collaboration.configure({
|
||||
// document: this.ydoc
|
||||
// }),
|
||||
FontFamily,
|
||||
Highlight.configure({
|
||||
multicolor: true
|
||||
}),
|
||||
Image,
|
||||
Mention.configure({
|
||||
// TODO: suggestions
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: 'Enter some content here...'
|
||||
}),
|
||||
Table.configure({
|
||||
resizable: true
|
||||
}),
|
||||
TableRow,
|
||||
TableHeader,
|
||||
TableCell,
|
||||
TaskList,
|
||||
TaskItem,
|
||||
TextAlign,
|
||||
TextStyle,
|
||||
Typography
|
||||
],
|
||||
onUpdate: ({ editor }) => {
|
||||
editorStore.$patch({
|
||||
lastChangeTimestamp: Temporal.Now.instant()
|
||||
})
|
||||
pageStore.$patch({
|
||||
content: JSON.stringify(editor.getJSON()),
|
||||
// -> What the author has typed IS the source, whatever the load did or did not deliver; see
|
||||
// the guard in `pageSave`
|
||||
contentLoaded: true,
|
||||
render: editor.getHTML()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function insertTable() {
|
||||
// this.ql.getModule('table').insertTable(3, 3)
|
||||
}
|
||||
function snapshot() {
|
||||
// console.info(Y.encodeStateVector(this.ydoc))
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(() => {
|
||||
// init()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
editor.value.destroy()
|
||||
})
|
||||
|
||||
init()
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.wysiwyg-container {
|
||||
height: calc(100% - 41px);
|
||||
|
||||
.wysiwyg-toolbar {
|
||||
border: none;
|
||||
border-bottom: 1px solid $grey-4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px;
|
||||
background: linear-gradient(to top, $grey-1 0%, #fff 100%);
|
||||
}
|
||||
|
||||
.ProseMirror {
|
||||
padding: 16px;
|
||||
min-height: 75vh;
|
||||
|
||||
&-focused {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
> * + * {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(#616161, 0.1);
|
||||
color: #616161;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: #0d0d0d;
|
||||
color: #fff;
|
||||
font-family: 'JetBrainsMono', monospace;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
code {
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
background: none;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid rgba(#0d0d0d, 0.1);
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 2px solid rgba(#0d0d0d, 0.1);
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
|
||||
td,
|
||||
th {
|
||||
min-width: 1em;
|
||||
border: 2px solid #ced4da;
|
||||
padding: 3px 5px;
|
||||
vertical-align: top;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
|
||||
> * {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
background-color: #f1f3f5;
|
||||
}
|
||||
|
||||
.selectedCell:after {
|
||||
z-index: 2;
|
||||
position: absolute;
|
||||
content: '';
|
||||
left: 0;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
background: rgba(200, 200, 255, 0.4);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.column-resize-handle {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
top: 0;
|
||||
bottom: -2px;
|
||||
width: 4px;
|
||||
background-color: #adf;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tableWrapper {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.resize-cursor {
|
||||
cursor: ew-resize;
|
||||
cursor: col-resize;
|
||||
}
|
||||
|
||||
ul[data-type='taskList'] {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> label {
|
||||
flex: 0 0 auto;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.is-editor-empty:first-child::before {
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
color: #ced4da;
|
||||
pointer-events: none;
|
||||
height: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,7 +2,10 @@
|
||||
<div class="site-header bg-header text-white">
|
||||
<div class="flex flex-nowrap">
|
||||
<w-toolbar style="height: 64px">
|
||||
<w-btn dense flat :to="homePath">
|
||||
<!-- -> `to` still does the navigating, so the logo stays a real link -- middle click, open in
|
||||
a new tab, the address in the status bar. The handler is only what a navigation cannot
|
||||
do on its own; see `leaveEditorForHome` -->
|
||||
<w-btn dense flat :to="homePath" @click="leaveEditorForHome">
|
||||
<w-avatar v-if="siteStore.logoText" size="34px" square>
|
||||
<img :src="`/_site/current/logo`" />
|
||||
</w-avatar>
|
||||
@@ -140,6 +143,8 @@ import { useMinWidth } from '@/composables/screen'
|
||||
import { splitLocalePath } from '@/helpers/pagePaths'
|
||||
|
||||
import { useCommonStore } from '@/stores/common'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { usePageStore } from '@/stores/page'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
@@ -158,6 +163,8 @@ import HeaderSearch from '@/components/HeaderSearch.vue'
|
||||
// STORES
|
||||
|
||||
const commonStore = useCommonStore()
|
||||
const editorStore = useEditorStore()
|
||||
const pageStore = usePageStore()
|
||||
const siteStore = useSiteStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
@@ -235,6 +242,38 @@ onBeforeUnmount(() => {
|
||||
|
||||
// METHODS
|
||||
|
||||
/**
|
||||
* Out of the editor, on the way home. The navigation itself is the link's.
|
||||
*
|
||||
* The logo is the way out of any screen the wiki can get into, which is why it is drawn in every one
|
||||
* of them -- so it has to close the editor as well as go home. Leaving the editor is otherwise the
|
||||
* job of the page view's route watcher, and that cannot help here: the editor opens WITHOUT changing
|
||||
* the URL, so an author editing the home page is already at `/`, the router treats the push as a
|
||||
* duplicate and runs no navigation at all, and the watcher never fires. The result was a logo that
|
||||
* looked broken on the one page it is most likely to be tried from.
|
||||
*
|
||||
* The page is re-read in exactly that case. Nothing else is going to: the store holds what the editor
|
||||
* last wrote into it -- the source AND the render -- so closing the editor over it would draw
|
||||
* somebody's unsaved draft as though it were the page. Where the click really does navigate, the
|
||||
* destination is loaded anyway and a reload here would be a second request for a page already on its
|
||||
* way.
|
||||
*
|
||||
* A page being CREATED never reaches that branch: it is written at `/_create/...`, which is never the
|
||||
* home path, so the click is a real navigation and the page view's watcher does the rest.
|
||||
*/
|
||||
function leaveEditorForHome() {
|
||||
if (!editorStore.isActive) {
|
||||
return
|
||||
}
|
||||
const isSamePlace = route.path === homePath.value
|
||||
editorStore.closeEditor()
|
||||
if (isSamePlace && pageStore.id) {
|
||||
// -> Failing to put the page back is not a reason to keep the reader in an editor they have just
|
||||
// left; the view draws what it has
|
||||
pageStore.pageLoad({ id: pageStore.id }).catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Ctrl+K below 600px, where the field is not mounted and so cannot claim the shortcut itself: this
|
||||
opens the row, and `HeaderSearch` focuses on mount. Above the breakpoint, and while the row is
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
<template>
|
||||
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
|
||||
<w-card style="width: 420px; max-width: 90vw">
|
||||
<w-card-header>{{ t('editor.visual.image.title') }}</w-card-header>
|
||||
<w-card-section class="flex flex-col gap-4">
|
||||
<!--
|
||||
First, because it is the half of this dialog that is about what the picture MEANS -- and the
|
||||
half an author is most likely to have come here without knowing they needed.
|
||||
-->
|
||||
<w-input
|
||||
v-model="state.alt"
|
||||
:label="t('editor.visual.image.alt')"
|
||||
:hint="t('editor.visual.image.altHint')"
|
||||
autofocus />
|
||||
|
||||
<div class="flex items-start gap-3">
|
||||
<w-input
|
||||
class="flex-1"
|
||||
v-model="state.width"
|
||||
:label="t('editor.visual.image.width')"
|
||||
:rules="[sizeRule]"
|
||||
spellcheck="false" />
|
||||
<w-input
|
||||
class="flex-1"
|
||||
v-model="state.height"
|
||||
:label="t('editor.visual.image.height')"
|
||||
:rules="[sizeRule]"
|
||||
spellcheck="false" />
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Both empty is what an image with no size suffix at all is, so it is offered as a value and
|
||||
not as a thing to be cleared field by field.
|
||||
-->
|
||||
<div class="text-caption text-black/60 dark:text-white/70">
|
||||
{{ t('editor.visual.image.sizeHint') }}
|
||||
<template v-if="naturalWidth > 0">
|
||||
<br />
|
||||
{{ t('editor.visual.image.natural', { width: naturalWidth, height: naturalHeight }) }}
|
||||
</template>
|
||||
</div>
|
||||
</w-card-section>
|
||||
<w-card-actions align="right">
|
||||
<w-btn flat :label="t('common.actions.cancel')" @click="onDialogCancel" />
|
||||
<w-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:label="t('common.actions.apply')"
|
||||
:disabled="!isValid"
|
||||
@click="submit" />
|
||||
</w-card-actions>
|
||||
</w-card>
|
||||
</w-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||
|
||||
/**
|
||||
* What an image is, besides the file it points at: the words that stand in for it, and how big it is
|
||||
* drawn.
|
||||
*
|
||||
* The alt text is what a reader who cannot see the picture is given, and what stands in its place
|
||||
* when it fails to load — so it belongs to the page rather than to the file, which is why it is
|
||||
* edited here and not left to whatever the file manager called it. Empty is a real answer: a picture
|
||||
* that carries no meaning of its own is better skipped by a screen reader than described.
|
||||
*
|
||||
* Both dimensions are text and not numbers, because `markdown-it-imsize` takes a percentage as
|
||||
* readily as a pixel count — `=50%x` is a legitimate half-width image — and a number field would
|
||||
* refuse to hold one. Either may be left empty: with only a width, the browser keeps the picture's
|
||||
* own proportions, which is what an author resizing a photograph almost always means.
|
||||
*/
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
/** What stands in for the picture. Empty where it is decoration and says nothing. */
|
||||
alt: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** The width as the page holds it — a pixel count or a percentage, both as written. */
|
||||
width: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** The height, likewise. */
|
||||
height: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** The picture's own size, from the element that is drawing it. Zero when it has not loaded. */
|
||||
naturalWidth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
naturalHeight: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
// EMITS
|
||||
|
||||
defineEmits([...dialogComponentEmits])
|
||||
|
||||
// DIALOG
|
||||
|
||||
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
alt: props.alt,
|
||||
width: props.width,
|
||||
height: props.height
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/*
|
||||
Exactly what `markdown-it-imsize` parses: digits, optionally followed by a percent sign. Anything
|
||||
else is not rejected by the plugin so much as ignored by it — it stops reading at the first
|
||||
character it does not know and the whole suffix stops being a size — so a value it cannot read would
|
||||
silently do nothing at all.
|
||||
*/
|
||||
const SIZE = /^\d+%?$/
|
||||
|
||||
const isValid = computed(() => sizeRule(state.width) === true && sizeRule(state.height) === true)
|
||||
|
||||
// METHODS
|
||||
|
||||
function sizeRule(value) {
|
||||
const text = String(value ?? '').trim()
|
||||
return text === '' || SIZE.test(text) ? true : t('editor.visual.image.sizeInvalid')
|
||||
}
|
||||
|
||||
function submit() {
|
||||
onDialogOK({
|
||||
// -> Not trimmed away to nothing by accident: a lone space is emptiness, and emptiness is a value
|
||||
alt: state.alt.trim(),
|
||||
width: state.width.trim(),
|
||||
height: state.height.trim()
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
|
||||
<w-card style="width: 560px; max-width: 90vw">
|
||||
<w-card-header>{{ t('editor.visual.link.title') }}</w-card-header>
|
||||
<w-card-section class="flex flex-col gap-4">
|
||||
<w-input
|
||||
v-model="state.text"
|
||||
:label="t('editor.visual.link.text')"
|
||||
:hint="t('editor.visual.link.textHint')"
|
||||
autofocus />
|
||||
|
||||
<!--
|
||||
The address, with the picker beside it. Typed directly most of the time -- an author editing
|
||||
a link usually knows what they are changing it to -- and browsed when the target is a page of
|
||||
this wiki, which is what `LinkPickerDialog` is for and what knows how to build its href.
|
||||
-->
|
||||
<div class="flex items-end gap-2">
|
||||
<w-input
|
||||
class="flex-1"
|
||||
v-model="state.href"
|
||||
:label="t('editor.visual.link.url')"
|
||||
spellcheck="false" />
|
||||
<w-btn
|
||||
flat
|
||||
color="primary"
|
||||
icon="la:folder-open"
|
||||
:label="t('editor.visual.link.browse')"
|
||||
no-caps
|
||||
@click="browse" />
|
||||
</div>
|
||||
|
||||
<w-input
|
||||
v-model="state.linkTitle"
|
||||
:label="t('editor.visual.link.tooltip')"
|
||||
:hint="t('editor.visual.link.tooltipHint')" />
|
||||
|
||||
<!-- -> Written into the page as `{target="_blank"}`, which is what both editors use -->
|
||||
<w-checkbox v-model="state.newTab" :label="t('editor.visual.link.newTab')" />
|
||||
</w-card-section>
|
||||
<w-card-actions align="right">
|
||||
<w-btn flat :label="t('common.actions.cancel')" @click="onDialogCancel" />
|
||||
<w-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:label="t('common.actions.apply')"
|
||||
:disabled="!state.href.trim()"
|
||||
@click="submit" />
|
||||
</w-card-actions>
|
||||
</w-card>
|
||||
</w-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { dialog, dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||
|
||||
import LinkPickerDialog from '@/components/LinkPickerDialog.vue'
|
||||
|
||||
/**
|
||||
* The three things a link is: the words it is on, where it goes, and what it says on hover.
|
||||
*
|
||||
* Opened over a link already in the page — see `links.js`, which finds the one the caret is in. The
|
||||
* text is here rather than left to be retyped in the document because a link is edited as one object:
|
||||
* an author changing where it points very often wants to change what it says in the same breath, and
|
||||
* selecting exactly the marked run first is fiddly.
|
||||
*/
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
/** The words the link is on. */
|
||||
text: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** Where it goes. */
|
||||
href: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** Markdown's link title — what a browser shows as a tooltip. Rarely set, never required. */
|
||||
linkTitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/** Whether the link opens in a new tab, which the page stores as `{target="_blank"}`. */
|
||||
newTab: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
// EMITS
|
||||
|
||||
defineEmits([...dialogComponentEmits])
|
||||
|
||||
// DIALOG
|
||||
|
||||
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
text: props.text,
|
||||
href: props.href,
|
||||
linkTitle: props.linkTitle,
|
||||
newTab: props.newTab
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
/**
|
||||
* Pick a page or build a URL with the picker, and take its href.
|
||||
*
|
||||
* Only the href: the picker also answers with the page's title, and writing that over the text would
|
||||
* throw away wording an author chose. It fills the text in only when there is none to lose — a link
|
||||
* whose words are empty, which is what a freshly inserted one has.
|
||||
*/
|
||||
function browse() {
|
||||
dialog({
|
||||
component: LinkPickerDialog,
|
||||
componentProps: {
|
||||
initialHref: state.href,
|
||||
okLabel: t('common.actions.select'),
|
||||
// -> This dialog asks the question itself, just below; two controls for it would disagree
|
||||
newTabOption: false
|
||||
}
|
||||
}).onOk((result) => {
|
||||
state.href = result?.href ?? state.href
|
||||
if (!state.text.trim() && result?.title) {
|
||||
state.text = result.title
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function submit() {
|
||||
onDialogOK({
|
||||
text: state.text.trim(),
|
||||
href: state.href.trim(),
|
||||
linkTitle: state.linkTitle.trim(),
|
||||
newTab: state.newTab
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -93,51 +93,50 @@
|
||||
</w-card>
|
||||
</w-menu>
|
||||
</w-btn>
|
||||
<!-- -> Nothing follows it on a redirection, and a rule with nothing under it is just a line -->
|
||||
<w-separator class="my-2" v-if="!isRedirect" inset />
|
||||
<!-- -> A rule with nothing under it is just a line: the three groups below can each be absent —
|
||||
on a redirection, on a page that has never been saved, and inside the editor -->
|
||||
<w-separator class="my-2" v-if="showHistory || showSource || showPageActions" inset />
|
||||
</template>
|
||||
<!--
|
||||
The three below are all about a page's TEXT: what it used to say, what it says in source, and
|
||||
the things that can be done to that text. A redirection has none — its content is a target, the
|
||||
the things that can be done to that text. Each carries its own test now rather than sharing a
|
||||
wrapper, because they are absent for three different reasons — see `showHistory`, `showSource`
|
||||
and `showPageActions`. A redirection loses all three either way: its content is a target, the
|
||||
form above is the whole of it, and there is no render for any of these to be about.
|
||||
-->
|
||||
<template v-if="!isRedirect">
|
||||
<!--
|
||||
`read:history` is the permission that exists to say who may see what a page used to contain, so
|
||||
the button follows it rather than page read access. The API asks the same question.
|
||||
-->
|
||||
<w-btn
|
||||
class="h-12"
|
||||
v-if="userStore.can(`read:history`)"
|
||||
flat
|
||||
icon="la:history"
|
||||
:color="editorStore.isActive ? `white` : `grey`"
|
||||
aria-label="Page History"
|
||||
@click="viewPageHistory">
|
||||
<w-tooltip anchor="center left" self="center right">Page History</w-tooltip>
|
||||
</w-btn>
|
||||
<!--
|
||||
`read:source` likewise, granted per path by a rule — so guests have it wherever a rule says so,
|
||||
and the API answers the overlay behind this button on exactly that permission.
|
||||
-->
|
||||
<w-btn
|
||||
class="h-12"
|
||||
v-if="canViewSource"
|
||||
flat
|
||||
icon="la:code"
|
||||
:color="editorStore.isActive ? `white` : `grey`"
|
||||
aria-label="Page Source"
|
||||
@click="viewPageSource">
|
||||
<w-tooltip anchor="center left" self="center right">Page Source</w-tooltip>
|
||||
</w-btn>
|
||||
</template>
|
||||
<!-- -> `hasPageActions` takes the rule with it: a separator over a button that opens nothing is a
|
||||
<!--
|
||||
`read:history` is the permission that exists to say who may see what a page used to contain, so
|
||||
the button follows it rather than page read access. The API asks the same question.
|
||||
-->
|
||||
<w-btn
|
||||
class="h-12"
|
||||
v-if="showHistory"
|
||||
flat
|
||||
icon="la:history"
|
||||
:color="editorStore.isActive ? `white` : `grey`"
|
||||
aria-label="Page History"
|
||||
@click="viewPageHistory">
|
||||
<w-tooltip anchor="center left" self="center right">Page History</w-tooltip>
|
||||
</w-btn>
|
||||
<!--
|
||||
`read:source` likewise, granted per path by a rule — so guests have it wherever a rule says so,
|
||||
and the API answers the overlay behind this button on exactly that permission.
|
||||
-->
|
||||
<w-btn
|
||||
class="h-12"
|
||||
v-if="showSource"
|
||||
flat
|
||||
icon="la:code"
|
||||
:color="editorStore.isActive ? `white` : `grey`"
|
||||
aria-label="Page Source"
|
||||
@click="viewPageSource">
|
||||
<w-tooltip anchor="center left" self="center right">Page Source</w-tooltip>
|
||||
</w-btn>
|
||||
<!-- -> `showPageActions` takes the rule with it: a separator over a button that opens nothing is a
|
||||
line drawn for its own sake -->
|
||||
<template
|
||||
v-if="
|
||||
hasPageActions && !isRedirect && !(editorStore.isActive && editorStore.mode === `create`)
|
||||
">
|
||||
<w-separator class="my-2" inset />
|
||||
<template v-if="showPageActions">
|
||||
<!-- -> And only where there is something above to separate it FROM -->
|
||||
<w-separator class="my-2" v-if="showHistory || showSource" inset />
|
||||
<w-btn
|
||||
class="h-12"
|
||||
flat
|
||||
@@ -157,14 +156,21 @@
|
||||
auto-close
|
||||
transition-show="jump-left">
|
||||
<w-list padding style="min-width: 225px">
|
||||
<!--
|
||||
Only where there is somewhere to convert TO: `convertibleTo` is the editors that produce
|
||||
this page's content type, and the site has to have one of them turned on. A redirection
|
||||
has no peer at all, which is why this is absent rather than disabled on one.
|
||||
-->
|
||||
<w-item
|
||||
clickable
|
||||
disabled
|
||||
v-if="flagsStore.experimental && userStore.can(`manage:pages`)">
|
||||
v-if="userStore.can(`write:pages`) && canConvert"
|
||||
@click="convertPage">
|
||||
<w-item-section class="items-center" avatar>
|
||||
<w-icon class="text-deep-orange-9" name="la:atom" size="sm" />
|
||||
</w-item-section>
|
||||
<w-item-section><w-item-label>Convert Page</w-item-label></w-item-section>
|
||||
<w-item-section
|
||||
><w-item-label>{{ t('convertPage.action') }}</w-item-label></w-item-section
|
||||
>
|
||||
</w-item>
|
||||
<w-item clickable v-if="userStore.can(`write:pages`)" @click="rerenderPage">
|
||||
<w-item-section class="items-center" avatar>
|
||||
@@ -270,6 +276,16 @@ const menuPendingAssets = ref(null)
|
||||
|
||||
const hasPendingAssets = computed(() => editorStore.pendingAssets?.length > 0)
|
||||
|
||||
/**
|
||||
* Whether this page has anywhere to be converted to.
|
||||
*
|
||||
* Both halves matter: the server says which editors produce this page's content type, and the site
|
||||
* says which of those anybody can open. A redirection satisfies neither.
|
||||
*/
|
||||
const canConvert = computed(() =>
|
||||
(pageStore.convertibleTo ?? []).some((editor) => siteStore.activeEditors.includes(editor))
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether the page this rail is for is a redirection — one being read, edited or created alike, since
|
||||
* `pageCreate` puts the editor on the page store as well.
|
||||
@@ -298,6 +314,36 @@ const canViewSource = computed(
|
||||
userStore.can('read:source') || userStore.can('write:pages') || userStore.can('manage:pages')
|
||||
)
|
||||
|
||||
/**
|
||||
* Whether this page exists on the server yet.
|
||||
*
|
||||
* `id` is `0` until the first save — see `pageCreate` — and the two buttons below it gate are both
|
||||
* questions about a stored page: what it used to say, and what it says in source. Asked of a page
|
||||
* that has never been saved, the API has nothing to answer with and both overlays open on an error.
|
||||
*
|
||||
* The id rather than `mode === 'create'`, because the question is about the page and not about how
|
||||
* the editor was opened.
|
||||
*/
|
||||
const isSaved = computed(() => Boolean(pageStore.id))
|
||||
|
||||
/** What a page's stored TEXT can be asked about, which needs a stored page and a page with text. */
|
||||
const showHistory = computed(
|
||||
() => !isRedirect.value && isSaved.value && userStore.can('read:history')
|
||||
)
|
||||
const showSource = computed(() => !isRedirect.value && isSaved.value && canViewSource.value)
|
||||
|
||||
/**
|
||||
* Whether the "..." menu is offered at all.
|
||||
*
|
||||
* Never while an editor is open, whatever the page. Converting a page rewrites the source under the
|
||||
* editor holding it, and re-rendering it acts on the version that is STORED — so both are answers to
|
||||
* a question about the saved page, asked from a screen showing an unsaved one. Neither belongs beside
|
||||
* Save and Discard.
|
||||
*/
|
||||
const showPageActions = computed(
|
||||
() => hasPageActions.value && !isRedirect.value && !editorStore.isActive
|
||||
)
|
||||
|
||||
// METHODS
|
||||
|
||||
function togglePageProperties() {
|
||||
@@ -322,6 +368,19 @@ function viewPageSource() {
|
||||
siteStore.$patch({ overlay: 'PageSource', overlayOpts: {} })
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the page to a different editor.
|
||||
*
|
||||
* The dialog is what decides whether that is safe — see `PageConvertDialog`, which rewrites the source
|
||||
* and compares the two renders before offering to go ahead. Nothing is reloaded here: the dialog does
|
||||
* it, because it knows whether anything was written.
|
||||
*/
|
||||
function convertPage() {
|
||||
dialog({
|
||||
component: defineAsyncComponent(() => import('../components/PageConvertDialog.vue'))
|
||||
})
|
||||
}
|
||||
|
||||
function rerenderPage() {
|
||||
dialog({
|
||||
component: defineAsyncComponent(() => import('../components/RerenderPageDialog.vue')),
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
<template>
|
||||
<w-dialog v-model="dialogVisible" persistent @hide="onDialogHide">
|
||||
<w-card style="width: 560px; max-width: 100%">
|
||||
<w-card-header>{{ t('convertPage.title') }}</w-card-header>
|
||||
<w-linear-progress v-if="state.busy" query />
|
||||
|
||||
<w-card-section>
|
||||
<div class="text-sm opacity-70 mb-3">
|
||||
{{ t('convertPage.intro', { editor: editorName(pageStore.editor) }) }}
|
||||
</div>
|
||||
|
||||
<!--
|
||||
The editors this page could be opened with: what the server says its content type allows,
|
||||
narrowed to what this site has actually turned on. An editor nobody can open is not an
|
||||
offer.
|
||||
-->
|
||||
<w-list padding bordered>
|
||||
<w-item
|
||||
v-for="target of targets"
|
||||
:key="target"
|
||||
clickable
|
||||
:active="state.target === target"
|
||||
active-class="text-primary"
|
||||
@click="selectTarget(target)">
|
||||
<w-item-section side><w-icon name="la:pen-fancy" /></w-item-section>
|
||||
<w-item-section>
|
||||
<w-item-label>{{ editorName(target) }}</w-item-label>
|
||||
<w-item-label caption>{{ t(`convertPage.describe.${target}`) }}</w-item-label>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-list>
|
||||
|
||||
<!--
|
||||
What converting will actually do to the file. The source is rewritten only in one direction,
|
||||
and only then is there anything to warn about — see `convert.js`.
|
||||
-->
|
||||
<div v-if="state.checked" class="mt-4">
|
||||
<w-banner v-if="state.error" class="bg-red-1 text-red-9" dense>
|
||||
{{ t('convertPage.failed', { message: state.error }) }}
|
||||
</w-banner>
|
||||
<w-banner v-else-if="!state.identical" class="bg-orange-1 text-orange-9" dense>
|
||||
<div class="font-semibold">{{ t('convertPage.wouldChange') }}</div>
|
||||
<div class="text-xs mt-2 font-mono whitespace-pre-wrap opacity-80">
|
||||
{{ t('convertPage.wasRendered') }} {{ state.difference?.before }}
|
||||
</div>
|
||||
<div class="text-xs mt-1 font-mono whitespace-pre-wrap opacity-80">
|
||||
{{ t('convertPage.wouldRender') }} {{ state.difference?.after }}
|
||||
</div>
|
||||
</w-banner>
|
||||
<w-banner v-else-if="state.changed" class="bg-blue-1 text-blue-9" dense>
|
||||
{{ t('convertPage.willReformat') }}
|
||||
</w-banner>
|
||||
<w-banner v-else class="bg-green-1 text-green-9" dense>
|
||||
{{ t('convertPage.noSourceChange') }}
|
||||
</w-banner>
|
||||
</div>
|
||||
</w-card-section>
|
||||
|
||||
<w-card-actions align="right">
|
||||
<w-btn flat :label="t('common.actions.cancel')" @click="onDialogCancel" />
|
||||
<w-btn
|
||||
unelevated
|
||||
color="primary"
|
||||
:label="t('convertPage.convert')"
|
||||
:disabled="!state.target || state.busy || Boolean(state.error)"
|
||||
@click="convert" />
|
||||
</w-card-actions>
|
||||
</w-card>
|
||||
</w-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { apiErrorMessage } from '@/helpers/apiError'
|
||||
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { usePageStore } from '@/stores/page'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
/**
|
||||
* Moving a page from one editor to another.
|
||||
*
|
||||
* A page has exactly one editor and everyone who opens it gets that one, so this is a deliberate act
|
||||
* on the page rather than a preference of whoever is reading it — which is why it lives with the
|
||||
* other page actions and asks before it does anything.
|
||||
*
|
||||
* The interesting part is the check. Converting to the Visual editor rewrites the source into the
|
||||
* form that editor writes, and rather than trusting that rewrite to be faithful, the page is rendered
|
||||
* both ways and the two are compared. An author is told what they are about to get: nothing at all,
|
||||
* a reformatted file that renders identically, or — if the serialiser cannot express something the
|
||||
* page contains — the difference itself, with Convert refused.
|
||||
*/
|
||||
|
||||
// EMITS
|
||||
|
||||
defineEmits([...dialogComponentEmits])
|
||||
|
||||
// DIALOG
|
||||
|
||||
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
|
||||
|
||||
// STORES
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const pageStore = usePageStore()
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
target: '',
|
||||
busy: false,
|
||||
checked: false,
|
||||
changed: false,
|
||||
identical: true,
|
||||
difference: null,
|
||||
error: null,
|
||||
content: '',
|
||||
render: ''
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/**
|
||||
* What this page can become: the content-type peers the server named, narrowed to the editors the
|
||||
* site has enabled. Both halves are needed — the first is what would not lose anything, the second is
|
||||
* what somebody could actually open afterwards.
|
||||
*/
|
||||
const targets = computed(() =>
|
||||
(pageStore.convertibleTo ?? []).filter((editor) => siteStore.activeEditors.includes(editor))
|
||||
)
|
||||
|
||||
// METHODS
|
||||
|
||||
function editorName(editor) {
|
||||
return t(`admin.editors.${editor}Name`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a target, and work out what converting to it would do.
|
||||
*
|
||||
* The whole check runs in the browser because the markdown pipeline does: parsing, serialising and
|
||||
* rendering are all here, and the server has no way to do any of it without a headless browser.
|
||||
*/
|
||||
async function selectTarget(target) {
|
||||
state.target = target
|
||||
state.checked = false
|
||||
state.error = null
|
||||
state.difference = null
|
||||
|
||||
if (target !== 'visual') {
|
||||
/*
|
||||
Converting AWAY from the Visual editor rewrites nothing: what it wrote is markdown that every
|
||||
markdown editor reads unchanged. So there is nothing to check and nothing to warn about.
|
||||
*/
|
||||
state.changed = false
|
||||
state.identical = true
|
||||
state.checked = true
|
||||
return
|
||||
}
|
||||
|
||||
state.busy = true
|
||||
try {
|
||||
// -> Loaded on demand: this is the only screen in the app that needs the Visual editor's
|
||||
// machinery without opening the Visual editor
|
||||
const [{ createParser }, { serialize }, { normalizeForVisual }, { MarkdownRenderer }] =
|
||||
await Promise.all([
|
||||
import('@/editor/visual/parse'),
|
||||
import('@/editor/visual/serialize'),
|
||||
import('@/editor/visual/convert'),
|
||||
import('@/renderers/markdown')
|
||||
])
|
||||
|
||||
if (!editorStore.configIsLoaded) {
|
||||
await editorStore.fetchConfigs()
|
||||
}
|
||||
const source = await pageSource()
|
||||
const config = editorStore.editors.markdown ?? {}
|
||||
const renderer = new MarkdownRenderer(config)
|
||||
const result = normalizeForVisual(source, {
|
||||
parser: createParser(renderer, config),
|
||||
serialize,
|
||||
renderer,
|
||||
pagePath: pageStore.path
|
||||
})
|
||||
Object.assign(state, {
|
||||
changed: result.changed,
|
||||
identical: result.identical,
|
||||
difference: result.difference,
|
||||
error: result.error,
|
||||
content: result.content,
|
||||
render: result.render,
|
||||
checked: true
|
||||
})
|
||||
} catch (err) {
|
||||
state.error = err.message
|
||||
state.checked = true
|
||||
} finally {
|
||||
state.busy = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's source.
|
||||
*
|
||||
* The page view does not ask for content — a reader is served the render — so this is very often not
|
||||
* in the store yet, and converting is being done from the page view.
|
||||
*/
|
||||
async function pageSource() {
|
||||
if (pageStore.contentLoaded) {
|
||||
return pageStore.content
|
||||
}
|
||||
const data = await API_CLIENT.get(
|
||||
`sites/${siteStore.id}/pages/${pageStore.id}?withContent=true`
|
||||
).json()
|
||||
return data?.content ?? ''
|
||||
}
|
||||
|
||||
async function convert() {
|
||||
state.busy = true
|
||||
try {
|
||||
const body = { editor: state.target }
|
||||
/*
|
||||
The rewritten source only where there is one, and only when it actually differs: sending it
|
||||
unchanged would still cost a history version saying the page was edited when nothing about it
|
||||
was.
|
||||
*/
|
||||
if (state.target === 'visual' && state.changed) {
|
||||
body.content = state.content
|
||||
body.render = state.render
|
||||
}
|
||||
body.reasonForChange = t('convertPage.reason', { editor: editorName(state.target) })
|
||||
|
||||
await API_CLIENT.put(`sites/${siteStore.id}/pages/${pageStore.id}/editor`, { json: body })
|
||||
notify({
|
||||
type: 'positive',
|
||||
message: t('convertPage.success', { editor: editorName(state.target) })
|
||||
})
|
||||
// -> The page is reloaded rather than patched: its editor, its source and its render all moved
|
||||
await pageStore.pageLoad({ id: pageStore.id })
|
||||
onDialogOK()
|
||||
} catch (err) {
|
||||
notify({ type: 'negative', message: apiErrorMessage(err) })
|
||||
} finally {
|
||||
state.busy = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -593,10 +593,7 @@ async function discardChanges() {
|
||||
dropped -- and that is an edit to a page that exists, however the editor was last used.
|
||||
*/
|
||||
if (editorStore.isActive && editorStore.mode === 'create') {
|
||||
editorStore.$patch({
|
||||
isActive: false,
|
||||
editor: ''
|
||||
})
|
||||
editorStore.closeEditor()
|
||||
|
||||
/*
|
||||
Is it the home page in create mode? In whichever locale it was being written -- the test used to
|
||||
@@ -622,13 +619,10 @@ async function discardChanges() {
|
||||
for a moment at the route the editor was on, which a redirection reads as "nobody is holding
|
||||
me" and acts on -- taking the author to its target instead of back to the page they discarded.
|
||||
*/
|
||||
// -> Every field of it, not just `isActive`, and the editor's own path with them: see
|
||||
// `leaveEditor`. `cancelPageEdit` has already navigated, so this only settles the state.
|
||||
await pageStore.cancelPageEdit()
|
||||
editorStore.$patch({
|
||||
isActive: false,
|
||||
editor: '',
|
||||
// -> Back to the ordinary meaning of the editor, or the next thing opened would inherit this one
|
||||
mode: 'edit'
|
||||
})
|
||||
await pageStore.leaveEditor()
|
||||
if (hadPendingChanges) {
|
||||
notify({
|
||||
type: 'positive',
|
||||
@@ -641,7 +635,7 @@ async function discardChanges() {
|
||||
} catch (err) {
|
||||
// -> The editor closes either way: the reader asked to leave it, and a page that would not
|
||||
// reload is not a reason to keep them in it
|
||||
editorStore.$patch({ isActive: false, editor: '', mode: 'edit' })
|
||||
await pageStore.leaveEditor()
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: 'Failed to reload page state.'
|
||||
@@ -680,21 +674,12 @@ async function saveChangesCommit(closeAfter = false) {
|
||||
})
|
||||
if (closeAfter) {
|
||||
/*
|
||||
The editor closes onto the page, and for a redirection that page would take the author
|
||||
straight to the target they just chose. `editorExitPath` holds it instead — a change of query
|
||||
on the route already showing, so nothing is loaded again. Every other page is left alone,
|
||||
down to the fragment it was opened at.
|
||||
|
||||
Before the editor closes, and awaited: the page view drawn at the editor's route would read
|
||||
the query as it stands and follow the redirection out from under this.
|
||||
The editor closes onto the page it was opened on — `editorExitPath`, which for a redirection
|
||||
carries the query that stops the page taking the author straight to the target they have just
|
||||
chosen. `leaveEditor` navigates before it closes anything, which is what keeps that query in
|
||||
front of the page view rather than behind it.
|
||||
*/
|
||||
if (pageStore.editor === 'redirect' && route.fullPath !== pageStore.editorExitPath) {
|
||||
await router.replace(pageStore.editorExitPath)
|
||||
}
|
||||
editorStore.$patch({
|
||||
isActive: false,
|
||||
editor: ''
|
||||
})
|
||||
await pageStore.leaveEditor()
|
||||
}
|
||||
} catch (err) {
|
||||
notify({
|
||||
@@ -719,9 +704,7 @@ async function createPage() {
|
||||
type: 'positive',
|
||||
message: 'Homepage created successfully.'
|
||||
})
|
||||
editorStore.$patch({
|
||||
isActive: false
|
||||
})
|
||||
editorStore.closeEditor()
|
||||
// -> The home page that was just written, not the site root: unprefixed, the router sends it to
|
||||
// the PRIMARY locale, so creating the French home page landed on the English one
|
||||
router.replace(pageStore.editorExitPath)
|
||||
@@ -768,9 +751,7 @@ async function createPage() {
|
||||
type: 'positive',
|
||||
message: 'Page created successfully.'
|
||||
})
|
||||
editorStore.$patch({
|
||||
isActive: false
|
||||
})
|
||||
editorStore.closeEditor()
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
@@ -797,10 +778,15 @@ async function processPendingAssets() {
|
||||
}
|
||||
}
|
||||
|
||||
async function editPage() {
|
||||
loading.show()
|
||||
await pageStore.pageEdit()
|
||||
loading.hide()
|
||||
/**
|
||||
* Into the editor, which is a navigation and nothing else.
|
||||
*
|
||||
* The page view's route watcher is what opens it — `/_edit/...` is its branch, and it calls
|
||||
* `pageEdit` with the path and locale out of the URL. Opening it from here as well would load the
|
||||
* page twice and, worse, would leave the editor open at an address that says nothing about it.
|
||||
*/
|
||||
function editPage() {
|
||||
router.push(pageStore.editPath)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -841,11 +827,7 @@ async function submitSuggestionCommit(guest = {}) {
|
||||
await pageStore.pageSubmitSuggestion(guest)
|
||||
// -> Back to the page as everyone else sees it: what was typed is now a suggestion waiting for a
|
||||
// reviewer, not a version of the page, so leaving the editor open on it would be a lie
|
||||
editorStore.$patch({
|
||||
isActive: false,
|
||||
editor: '',
|
||||
mode: 'edit'
|
||||
})
|
||||
editorStore.closeEditor()
|
||||
await pageStore.pageLoad({ id: pageStore.id })
|
||||
notify({
|
||||
type: 'positive',
|
||||
|
||||
@@ -5,28 +5,28 @@
|
||||
The editors this site writes pages with, in the order the store lists them -- which is where
|
||||
the rule lives, so that what can be created here and what a search can be filtered by cannot
|
||||
drift apart. Each still carries its own icon and wording: `redirect` makes a redirection
|
||||
rather than a page, and `wysiwyg` is just "New Page".
|
||||
rather than a page, and both Markdown and Visual say which of the two they open.
|
||||
-->
|
||||
<w-item
|
||||
v-for="editor of siteStore.activeEditors"
|
||||
:key="editor"
|
||||
clickable
|
||||
@click="create(editor)">
|
||||
<blueprint-icon :icon="NEW_PAGE_ITEMS[editor].icon" />
|
||||
<w-item-section class="pr-2">{{ NEW_PAGE_ITEMS[editor].label }}</w-item-section>
|
||||
<blueprint-icon :icon="EDITOR_ICONS[editor]" />
|
||||
<w-item-section class="pr-2">{{ t(`common.createPage.${editor}`) }}</w-item-section>
|
||||
</w-item>
|
||||
<template v-if="props.hideAssetBtn === false">
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item clickable @click="openFileManager">
|
||||
<blueprint-icon icon="add-image" />
|
||||
<w-item-section class="pr-2">Upload Media Asset</w-item-section>
|
||||
<w-item-section class="pr-2">{{ t('common.createPage.uploadAsset') }}</w-item-section>
|
||||
</w-item>
|
||||
</template>
|
||||
<template v-if="props.showNewFolder">
|
||||
<w-separator class="my-2" inset />
|
||||
<w-item clickable @click="newFolder">
|
||||
<blueprint-icon icon="add-folder" />
|
||||
<w-item-section class="pr-2">New Folder</w-item-section>
|
||||
<w-item-section class="pr-2">{{ t('common.actions.newFolder') }}</w-item-section>
|
||||
</w-item>
|
||||
</template>
|
||||
</w-list>
|
||||
@@ -43,21 +43,23 @@ import { usePageStore } from '@/stores/page'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
/**
|
||||
* How each editor is offered: its icon, and the wording of the item that creates one with it.
|
||||
* The icon each editor is offered under.
|
||||
*
|
||||
* WHICH of them are offered is `siteStore.activeEditors`, and only that list decides -- these are the
|
||||
* words for the ones that are.
|
||||
* WHICH of them are offered, and in what order, is `siteStore.activeEditors` -- only that list
|
||||
* decides. The wording lives in the locale file as `common.createPage.<editor>`, keyed by the same
|
||||
* ids, so an editor added to that list needs an icon here and a string there.
|
||||
*
|
||||
* `redirect` is not an editor the site can turn off, because it authors nothing: a redirection is a
|
||||
* page with a target instead of a body.
|
||||
*/
|
||||
const NEW_PAGE_ITEMS = {
|
||||
wysiwyg: { icon: 'google-presentation', label: 'New Page' },
|
||||
markdown: { icon: 'markdown', label: 'New Markdown Page' },
|
||||
asciidoc: { icon: 'asciidoc', label: 'New AsciiDoc Page' },
|
||||
channel: { icon: 'chat', label: 'New Discussion Space' },
|
||||
blog: { icon: 'typewriter-with-paper', label: 'New Blog Page' },
|
||||
api: { icon: 'api', label: 'New API Documentation' },
|
||||
// -> Not an editor the site can turn off, because it authors nothing: a redirection is a page with
|
||||
// a target instead of a body
|
||||
redirect: { icon: 'advance', label: 'New Redirection' }
|
||||
const EDITOR_ICONS = {
|
||||
markdown: 'markdown',
|
||||
visual: 'google-presentation',
|
||||
asciidoc: 'asciidoc',
|
||||
channel: 'chat',
|
||||
blog: 'typewriter-with-paper',
|
||||
api: 'api',
|
||||
redirect: 'advance'
|
||||
}
|
||||
|
||||
// PROPS
|
||||
|
||||
@@ -342,6 +342,22 @@
|
||||
</div>
|
||||
</w-form>
|
||||
</w-card-section>
|
||||
|
||||
<!--
|
||||
The page's id, last and quietly.
|
||||
|
||||
Only once there is one: a page being created has no id until the first save, and a field
|
||||
reading `0` would be a fact about this dialog rather than about the page. It is here because
|
||||
it is the one name a page never loses — an alias can be retyped and a path can be moved — so
|
||||
it is what a bug report, a support thread or a link that has to keep working is built on, and
|
||||
`/i/<id>` is where that link goes.
|
||||
-->
|
||||
<w-card-section v-if="pageStore.id" class="pt-3 pb-4">
|
||||
<div class="text-caption text-black/50 dark:text-white/40">
|
||||
{{ t('editor.props.pageId') }}
|
||||
<span class="font-mono select-all">{{ pageStore.id }}</span>
|
||||
</div>
|
||||
</w-card-section>
|
||||
</w-scroll-area>
|
||||
<w-dialog v-model="state.showLocaleRelationsDialog">
|
||||
<page-locale-relations-dialog @close="state.showLocaleRelationsDialog = false" />
|
||||
|
||||
@@ -9,10 +9,7 @@
|
||||
<w-btn push color="primary" :label="t(`welcome.createHome`)" icon="la:plus" no-caps>
|
||||
<w-menu class="translucent-menu" auto-close anchor="top left" self="bottom left">
|
||||
<w-list padding>
|
||||
<w-item
|
||||
clickable
|
||||
@click="createHomePage(`wysiwyg`)"
|
||||
v-if="flagsStore.experimental && siteStore.editors.wysiwyg">
|
||||
<w-item clickable @click="createHomePage(`visual`)" v-if="siteStore.editors.visual">
|
||||
<blueprint-icon icon="google-presentation" />
|
||||
<w-item-section class="pr-2">Using the Visual Editor</w-item-section>
|
||||
<w-item-section side><w-icon name="mdi:chevron-right" /></w-item-section>
|
||||
|
||||
@@ -72,6 +72,15 @@ let stopWatchers = []
|
||||
* store back into the document does not send it round again.
|
||||
*/
|
||||
let applyingRemote = false
|
||||
/**
|
||||
* Whether the room's opening state has been taken in yet.
|
||||
*
|
||||
* Everything that arrives before this is the room as it already was, and a new room is not empty: the
|
||||
* server seeds one with the page's stored title, description and icon (`core/collab.ts`). So until it
|
||||
* is set, an incoming header field is measured against what the page store loaded from the API and
|
||||
* ignored where the two agree — see `adoptProps`. After it, anything arriving is somebody's edit.
|
||||
*/
|
||||
let initialSyncDone = false
|
||||
|
||||
/**
|
||||
* A stable colour for a user.
|
||||
@@ -169,8 +178,19 @@ export function startCollabSession({ siteId, pageId }) {
|
||||
/*
|
||||
The room may have been holding header fields somebody else changed and has not saved. Those are
|
||||
the current state of this edit, so they win over what this browser loaded from the API.
|
||||
|
||||
`initial`, because a room nobody was in is not silent: the server seeds a new one with the
|
||||
page's STORED title, description and icon (`core/collab.ts`), so on this first exchange a value
|
||||
equal to the stored one carries no edit at all — see `adoptProps`.
|
||||
*/
|
||||
adoptProps()
|
||||
adoptProps({ initial: true })
|
||||
initialSyncDone = true
|
||||
/*
|
||||
And then this browser's own, which the watcher below will never publish: it fires on change, and
|
||||
anything typed into the properties panel before the editor was opened changed before there was a
|
||||
room to tell. Second, so that a genuine edit adopted just above is what gets written back.
|
||||
*/
|
||||
publishProps()
|
||||
refreshParticipants()
|
||||
})
|
||||
|
||||
@@ -198,10 +218,14 @@ export function startCollabSession({ siteId, pageId }) {
|
||||
}
|
||||
}, SYNC_TIMEOUT)
|
||||
|
||||
// -> A header field somebody else edited, arriving mid-session
|
||||
/*
|
||||
A header field somebody else edited, arriving mid-session — or the room's own opening state, which
|
||||
lands here as a remote update too and reaches this observer BEFORE the provider says it has
|
||||
synced. `initialSyncDone` is what tells the two apart.
|
||||
*/
|
||||
yprops.observe((event, transaction) => {
|
||||
if (!transaction.local) {
|
||||
adoptProps()
|
||||
adoptProps({ initial: !initialSyncDone })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -239,6 +263,20 @@ export function startCollabSession({ siteId, pageId }) {
|
||||
return { doc, ytext }
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's shared document and awareness, for an editor that binds itself.
|
||||
*
|
||||
* The Markdown editor hands its model to `bindCollabEditor` below and this composable owns the
|
||||
* binding. ProseMirror cannot be bound that way: y-prosemirror is a set of PLUGINS, so joining a
|
||||
* session means rebuilding the editor's state around them, which only the editor can do. So it takes
|
||||
* the two things it needs and does the binding itself — see `enableCollab` in `editor/visual`.
|
||||
*
|
||||
* @returns {?{ ydoc: object, awareness: object }} Null before a session has started.
|
||||
*/
|
||||
export function collabHandles() {
|
||||
return doc && provider ? { ydoc: doc, awareness: provider.awareness } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the Monaco model over to the session.
|
||||
*
|
||||
@@ -263,6 +301,7 @@ export function stopCollabSession() {
|
||||
clearTimeout(typingTimer)
|
||||
typingTimer = null
|
||||
typing = false
|
||||
initialSyncDone = false
|
||||
for (const stop of stopWatchers) {
|
||||
stop()
|
||||
}
|
||||
@@ -316,6 +355,17 @@ function markTyping() {
|
||||
}, TYPING_IDLE)
|
||||
}
|
||||
|
||||
/** This browser's header fields into the room, whatever they are. See the call in the sync handler. */
|
||||
function publishProps() {
|
||||
const pageStore = usePageStore()
|
||||
const yprops = doc.getMap('props')
|
||||
doc.transact(() => {
|
||||
writeProp(yprops, 'title', pageStore.title)
|
||||
writeProp(yprops, 'description', pageStore.description)
|
||||
writeProp(yprops, 'icon', pageStore.icon)
|
||||
})
|
||||
}
|
||||
|
||||
function writeProp(yprops, key, value) {
|
||||
const next = value ?? ''
|
||||
if (yprops.get(key) !== next) {
|
||||
@@ -323,8 +373,16 @@ function writeProp(yprops, key, value) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Copy the shared header fields into the page store, without echoing them back out. */
|
||||
function adoptProps() {
|
||||
/**
|
||||
* Copy the shared header fields into the page store, without echoing them back out.
|
||||
*
|
||||
* @param {object} [options]
|
||||
* @param {boolean} [options.initial] Whether this is the first exchange with the room, where a value
|
||||
* matching the stored one is the server's seed rather than anybody's edit — and so must not
|
||||
* overwrite a field this author changed in the properties panel before opening the editor.
|
||||
* Once the session is live, any value arriving IS an edit and wins as it did before.
|
||||
*/
|
||||
function adoptProps({ initial = false } = {}) {
|
||||
const pageStore = usePageStore()
|
||||
const yprops = doc.getMap('props')
|
||||
const patch = {}
|
||||
@@ -335,6 +393,9 @@ function adoptProps() {
|
||||
if (typeof value !== 'string' || (key === 'icon' && !value)) {
|
||||
continue
|
||||
}
|
||||
if (initial && value === pageStore.storedProps[key]) {
|
||||
continue
|
||||
}
|
||||
if (value !== pageStore[key]) {
|
||||
patch[key] = value
|
||||
}
|
||||
|
||||
@@ -0,0 +1,846 @@
|
||||
/*
|
||||
The Visual editor.
|
||||
|
||||
Two kinds of rule live here and it is worth keeping them apart. The editor's own furniture — the
|
||||
toolbars, the frame around a block, a node's header bar — is styled here in full. The CONTENT is
|
||||
not: the editing surface carries `page-contents`, the same class the page view and the Markdown
|
||||
editor's preview use, so an article looks the same in all three places by sharing one stylesheet
|
||||
rather than by three of them agreeing. What is here for content is only what a page never needs —
|
||||
the seams that show an author where a construct begins and ends.
|
||||
*/
|
||||
|
||||
/*
|
||||
The Sass variables, loaded explicitly. `vite.config.js` injects both of these into every SFC through
|
||||
`additionalData`, but that reaches only the files Vite itself hands to Sass -- a partial pulled in by
|
||||
`@use` arrives without it. Same note as `_page-contents.scss` and `_page-chrome.scss`, which are
|
||||
here for the same reason.
|
||||
*/
|
||||
@use 'sass:color';
|
||||
@use 'palette' as *;
|
||||
@use 'theme' as *;
|
||||
|
||||
/*
|
||||
A flex child of the article column, filling whatever it is given rather than a height of its own.
|
||||
|
||||
It used to take `calc(100% - 41px)`, carried over from the dead editor this replaced, where the 41px
|
||||
stood for nothing in this layout -- so the editor stopped 41px short of the bottom, the rail stopped
|
||||
with it, and what was left was a white strip belonging to nobody.
|
||||
*/
|
||||
.editor-visual {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&-main {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
The insert rail, dressed exactly as the Markdown editor's is -- same dark grey, same width, same
|
||||
white icons -- because the two editors are the same screen with a different middle, and a reader
|
||||
switching between them should not be switching interfaces.
|
||||
|
||||
The `border-top` is the trick that joins the two bars: the rail runs the full height of the
|
||||
editor, alongside the toolbar as well as below it, so its first 32px are painted in the toolbar's
|
||||
own blue to carry that bar across the corner. Its height and the toolbar's have to agree, which is
|
||||
why both are stated as 32px rather than left to their contents.
|
||||
*/
|
||||
&-sidebar {
|
||||
flex: 0 0 auto;
|
||||
width: 56px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
background-color: $dark-4;
|
||||
border-top: 32px solid color.adjust($primary, $lightness: -10%);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&-type {
|
||||
writing-mode: vertical-rl;
|
||||
text-orientation: mixed;
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
padding-bottom: 1rem;
|
||||
font-weight: 500;
|
||||
color: rgb(255 255 255 / 0.4);
|
||||
}
|
||||
|
||||
&-mid {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
The formatting bar, in the Markdown editor's blue with white icons. One line and a fixed height:
|
||||
it has to stay 32px for the rail's blue top to line up with it, and a bar that wrapped would take
|
||||
that join with it.
|
||||
|
||||
No darker block at the left, which is the one thing not carried over from the Markdown editor's
|
||||
bar. There it is a 60px `border-left` capping the column Monaco draws its line numbers in, so the
|
||||
gutter reads as its own thing all the way up. Nothing here has a gutter -- the surface below is
|
||||
the article, starting at the edge -- so the same block would be a darker rectangle standing for
|
||||
nothing.
|
||||
*/
|
||||
&-toolbar {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
background-color: $primary;
|
||||
color: #fff;
|
||||
|
||||
/*
|
||||
What a pressed button looks like here. The `primary` colour these used to take is the colour of
|
||||
the bar itself, so an active button was drawn in the background it sits on and was simply
|
||||
invisible; a wash of white reads on the blue and needs no second colour to maintain.
|
||||
*/
|
||||
.w-btn.is-active {
|
||||
background-color: rgb(255 255 255 / 0.22);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The scrollport. A flex column so the surface inside it can be told to fill the height rather than
|
||||
stopping at the end of the text -- see `-surface`.
|
||||
*/
|
||||
&-editor {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/*
|
||||
What ProseMirror is mounted into. It carries no padding of its own: padding here would be outside
|
||||
the editable, so the space it made would be space the caret cannot be put in -- which is the whole
|
||||
complaint against a bottom margin that only looks like room to type. The padding is on the
|
||||
editable itself instead, below.
|
||||
|
||||
`flex: 1 0 auto` on both this and the editable is what makes the document at least as tall as the
|
||||
pane, so a click anywhere in the empty space under a short page lands in the text rather than on
|
||||
nothing. Growing but never shrinking: a page longer than the pane keeps its own height and
|
||||
scrolls.
|
||||
*/
|
||||
&-surface {
|
||||
flex: 1 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/*
|
||||
The containing block for the link bar, which is positioned against this box rather than the
|
||||
viewport -- so it scrolls along with the link it belongs to and nothing has to listen for scroll.
|
||||
*/
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
.visual-editor-content {
|
||||
outline: none;
|
||||
flex: 1 0 auto;
|
||||
|
||||
/*
|
||||
1rem at the sides, and that figure is not free to change: `--content-bleed` is how far the rule
|
||||
under an `h1` reaches back through the padding of whatever holds the content, so that it starts at
|
||||
the edge of the column rather than at the text. `_page-contents.scss` defaults it to 1rem
|
||||
expecting exactly this much, which is also what the Markdown editor's preview pane pads -- pad
|
||||
differently and the rule overhangs or falls short by the difference.
|
||||
|
||||
The bottom is deeper, and is the one measurement here the page itself does not have: it is room to
|
||||
go on typing without the line being written sitting against the bottom edge of the pane. On the
|
||||
editable rather than around it, so it is somewhere the caret can actually go -- clicking it puts
|
||||
the cursor at the end of the document.
|
||||
*/
|
||||
padding: 1rem 1rem 6rem;
|
||||
|
||||
/*
|
||||
ProseMirror's own furniture, recoloured. The behaviour of each of these comes from the base
|
||||
stylesheets the component imports; what is set here is only what should follow the site's theme
|
||||
rather than the library's defaults.
|
||||
*/
|
||||
&.ProseMirror-selectednode,
|
||||
.ProseMirror-selectednode {
|
||||
outline-color: $primary;
|
||||
}
|
||||
|
||||
/*
|
||||
The narrowest a column may be dragged, which `prosemirror-tables` reads as a custom property and
|
||||
leaves undefined — without it a column with no explicit width has no minimum and collapses.
|
||||
*/
|
||||
--default-cell-min-width: 3rem;
|
||||
|
||||
/* The colours only; the geometry of both is in `prosemirror-tables`' own stylesheet. */
|
||||
.column-resize-handle {
|
||||
background-color: $primary;
|
||||
}
|
||||
|
||||
.selectedCell:after {
|
||||
background: rgba(25, 118, 210, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
Somebody else's caret, while a page is being written by more than one person.
|
||||
|
||||
The markup is y-prosemirror's own `defaultCursorBuilder`: a span carrying the colour on its border,
|
||||
with a label div inside it. Only the geometry is set here -- the colour comes from the awareness
|
||||
state, which is the same per-user colour `collabUserColor` gives the avatars in the header, so one
|
||||
person is one colour everywhere they appear.
|
||||
|
||||
The caret is zero-width and `position: relative`, so it sits BETWEEN two characters rather than
|
||||
taking a column of its own and pushing the text along as people move about.
|
||||
*/
|
||||
.ProseMirror-yjs-cursor {
|
||||
position: relative;
|
||||
margin-left: -1px;
|
||||
margin-right: -1px;
|
||||
border-left: 1px solid black;
|
||||
border-right: 1px solid black;
|
||||
border-color: orange;
|
||||
word-break: normal;
|
||||
pointer-events: none;
|
||||
|
||||
/* -> The name, above the caret and out of the flow so it never moves the line it is standing in */
|
||||
> div {
|
||||
position: absolute;
|
||||
top: -1.05em;
|
||||
left: -1px;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.2;
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
user-select: none;
|
||||
color: #fff;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px 3px 3px 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* -> What they have selected, painted behind the text at the alpha y-prosemirror appends */
|
||||
.ProseMirror-yjs-selection {
|
||||
mix-blend-mode: multiply;
|
||||
|
||||
.body--dark & {
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The bar under the link the caret is in.
|
||||
|
||||
Absolutely positioned inside the editing surface, which scrolls with the document -- see the
|
||||
`position: relative` there. `max-width` keeps a long address from running off the side of the pane,
|
||||
and the href is elided rather than wrapped so the bar stays one line tall.
|
||||
|
||||
It floats over the text below it, and `pointer-events` is what stops that being a trap: the bar
|
||||
itself is transparent to the mouse and only its controls take clicks, so a link on the line
|
||||
underneath can still be clicked THROUGH the bar hovering over it. Without that, the one thing a
|
||||
reader is most likely to aim at next -- the next link down -- is the one thing the bar makes
|
||||
unreachable, and clicking it silently acts on the link the bar was already showing.
|
||||
*/
|
||||
.visual-link-bar {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
max-width: min(32rem, 100%);
|
||||
padding: 4px 8px;
|
||||
border: 1px solid $grey-4;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.12);
|
||||
font-size: 0.75rem;
|
||||
|
||||
.body--dark & {
|
||||
border-color: $grey-8;
|
||||
background: $grey-10;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The image bar, which is the same furniture placed differently: above the top-left corner of the
|
||||
picture rather than under a line of text, since an image is a box and a link is a run.
|
||||
|
||||
`pointer-events` for the same reason as the link bar, and it matters more here: an image sits in a
|
||||
paragraph of its own, so what the bar hovers over is the last line of the text before it. The bar is
|
||||
transparent to the mouse and only its buttons take clicks, so a word underneath can still be clicked
|
||||
through it -- and the caret still goes where it was aimed.
|
||||
*/
|
||||
.visual-image-bar {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 4px 6px;
|
||||
border: 1px solid $grey-4;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.12);
|
||||
|
||||
.body--dark & {
|
||||
border-color: $grey-8;
|
||||
background: $grey-10;
|
||||
box-shadow: 0 2px 8px rgb(0 0 0 / 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
.visual-link-bar button,
|
||||
.visual-link-bar-href,
|
||||
.visual-image-bar button {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/*
|
||||
A selected image.
|
||||
|
||||
Its node view says what selection looks like, so ProseMirror's own `ProseMirror-selectednode` class
|
||||
is never added to it -- a node view that implements `selectNode` is taken at its word. The outline
|
||||
is drawn outside the picture rather than over it, so a border in the image itself is still the
|
||||
image's.
|
||||
*/
|
||||
.visual-editor-content img.is-selected {
|
||||
outline: 2px solid $primary;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.visual-link-bar-href {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: monospace;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
/*
|
||||
The alert kinds offered in the toolbar menu, each in the colour its admonition is drawn in.
|
||||
|
||||
The five hues are copied from `_page-contents.scss` rather than read from it, and the copy is
|
||||
deliberate. They are declared there as custom properties ON `.page-contents`, and `WMenu` teleports
|
||||
its content to `<body>`, where nothing of that element is in scope -- so `var(--content-info)` would
|
||||
resolve to nothing here. Putting `page-contents` on the menu instead would bring the whole content
|
||||
typography with it, since it sets `font-size`, `color` and `line-height` on itself, and a menu is not
|
||||
an article. So these are two places holding one set of values, and they have to be kept in step:
|
||||
`--content-info` and its four siblings are the source, in both themes.
|
||||
|
||||
The dark variants still reach the menu even though it is teleported, because `body--dark` is a class
|
||||
on `<body>` itself -- which IS an ancestor of the teleport target.
|
||||
*/
|
||||
.visual-alert-choice {
|
||||
&--note {
|
||||
color: #1867c0;
|
||||
}
|
||||
&--tip {
|
||||
color: #0f7b53;
|
||||
}
|
||||
&--important {
|
||||
color: #6f42c1;
|
||||
}
|
||||
&--warning {
|
||||
color: #a35a00;
|
||||
}
|
||||
&--caution {
|
||||
color: #c02636;
|
||||
}
|
||||
}
|
||||
|
||||
.body--dark {
|
||||
.visual-alert-choice {
|
||||
&--note {
|
||||
color: #6ab0ff;
|
||||
}
|
||||
&--tip {
|
||||
color: #4ecf9a;
|
||||
}
|
||||
&--important {
|
||||
color: #c4a7ff;
|
||||
}
|
||||
&--warning {
|
||||
color: #f3b661;
|
||||
}
|
||||
&--caution {
|
||||
color: #ff8b8b;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The bar above anything that is more than text — a block, a code fence, an alert. Deliberately quiet:
|
||||
it is scaffolding that has to be reachable without competing with the page it sits in.
|
||||
*/
|
||||
.visual-node-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 3px 8px;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.6;
|
||||
background-color: $grey-2;
|
||||
border-bottom: 1px solid $grey-4;
|
||||
user-select: none;
|
||||
|
||||
.body--dark & {
|
||||
background-color: $grey-9;
|
||||
border-bottom-color: $grey-8;
|
||||
}
|
||||
}
|
||||
|
||||
.visual-node-name {
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 600;
|
||||
color: $grey-7;
|
||||
|
||||
.body--dark & {
|
||||
color: $grey-5;
|
||||
}
|
||||
}
|
||||
|
||||
.visual-node-summary {
|
||||
color: $grey-6;
|
||||
font-family: monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.visual-node-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.visual-node-action {
|
||||
border: 1px solid $grey-4;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
padding: 1px 8px;
|
||||
font-size: 0.7rem;
|
||||
cursor: pointer;
|
||||
color: $grey-8;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.body--dark & {
|
||||
background: $grey-10;
|
||||
border-color: $grey-8;
|
||||
color: $grey-4;
|
||||
}
|
||||
}
|
||||
|
||||
/* A block component with no editable content: drawn as the real element. */
|
||||
.visual-block,
|
||||
.visual-block-container,
|
||||
.visual-code,
|
||||
.visual-raw {
|
||||
border: 1px solid $grey-4;
|
||||
border-radius: 4px;
|
||||
margin: 1rem 0;
|
||||
overflow: hidden;
|
||||
|
||||
.body--dark & {
|
||||
border-color: $grey-8;
|
||||
}
|
||||
|
||||
&.is-selected {
|
||||
border-color: $primary;
|
||||
box-shadow: 0 0 0 1px $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.visual-block-preview {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.visual-block-body {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/*
|
||||
A tabset, drawn as tabs.
|
||||
|
||||
The strip is the block's furniture and the panels are the article, so the seam between them is the
|
||||
one line of colour here: the active tab is lifted out of the strip onto the panel below it, exactly
|
||||
as `block-tabs` draws it on the page.
|
||||
*/
|
||||
.visual-tabs {
|
||||
border: 1px solid $grey-4;
|
||||
border-radius: 4px;
|
||||
margin: 1rem 0;
|
||||
overflow: hidden;
|
||||
|
||||
.body--dark & {
|
||||
border-color: $grey-8;
|
||||
}
|
||||
}
|
||||
|
||||
.visual-tabs-strip {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
padding: 4px 4px 0;
|
||||
background-color: $grey-2;
|
||||
border-bottom: 1px solid $grey-4;
|
||||
user-select: none;
|
||||
|
||||
.body--dark & {
|
||||
background-color: $grey-9;
|
||||
border-bottom-color: $grey-8;
|
||||
}
|
||||
}
|
||||
|
||||
.visual-tabs-tab {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid transparent;
|
||||
border-bottom: none;
|
||||
border-radius: 4px 4px 0 0;
|
||||
background: transparent;
|
||||
padding: 4px 12px;
|
||||
margin-bottom: -1px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
color: $grey-8;
|
||||
max-width: 14rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
/* -> Joined to the panel below by painting over the strip's own bottom rule */
|
||||
&.is-active {
|
||||
background: #fff;
|
||||
border-color: $grey-4;
|
||||
color: $primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.body--dark & {
|
||||
color: $grey-4;
|
||||
|
||||
&.is-active {
|
||||
background: $grey-10;
|
||||
border-color: $grey-8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.visual-tabs-add {
|
||||
border: 1px dashed $grey-5;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
padding: 2px 10px;
|
||||
margin: 0 0 4px 4px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.2;
|
||||
cursor: pointer;
|
||||
color: $grey-7;
|
||||
|
||||
&:hover {
|
||||
border-color: $primary;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
.body--dark & {
|
||||
border-color: $grey-7;
|
||||
color: $grey-5;
|
||||
}
|
||||
}
|
||||
|
||||
/* -> What can be done to the panel showing, pushed to the far end so the tabs stay a row of tabs */
|
||||
.visual-tabs-tools {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
.visual-tabs-panels {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
/*
|
||||
One panel at a time. `display: none` rather than `hidden`, because the tabset toggles a class on
|
||||
elements ProseMirror manages and a class is the least it can touch.
|
||||
*/
|
||||
.visual-tab-panel {
|
||||
display: none;
|
||||
|
||||
&.is-active {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.visual-code {
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
overflow-x: auto;
|
||||
background-color: $grey-1;
|
||||
|
||||
.body--dark & {
|
||||
background-color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: monospace;
|
||||
font-size: 0.85rem;
|
||||
white-space: pre;
|
||||
}
|
||||
}
|
||||
|
||||
.visual-code-params {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
color: $grey-8;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
min-width: 8rem;
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-4;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.body--dark & {
|
||||
color: $grey-4;
|
||||
|
||||
&:focus {
|
||||
background: $grey-10;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.visual-raw-source {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
padding: 0.75rem;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
resize: vertical;
|
||||
background-color: $grey-1;
|
||||
color: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.body--dark & {
|
||||
background-color: $grey-10;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
An alert. The class the page will carry is on the element too, so the admonition colours come from
|
||||
the content stylesheet; only the title row is the editor's own.
|
||||
*/
|
||||
.visual-alert {
|
||||
margin: 1rem 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.visual-alert-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 2px 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.visual-alert-kind {
|
||||
font-size: 0.65rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
font-weight: 700;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.visual-alert-title {
|
||||
flex: 1 1 auto;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
font-weight: 700;
|
||||
font-size: 0.95rem;
|
||||
color: inherit;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
|
||||
&::placeholder {
|
||||
opacity: 0.55;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
/* A footnote's body, labelled with the reference that points at it. */
|
||||
.visual-footnote {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: baseline;
|
||||
border-top: 1px dashed $grey-4;
|
||||
padding-top: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
|
||||
.body--dark & {
|
||||
border-top-color: $grey-8;
|
||||
}
|
||||
}
|
||||
|
||||
.visual-footnote-label {
|
||||
flex: 0 0 auto;
|
||||
font-family: monospace;
|
||||
font-size: 0.8rem;
|
||||
color: $grey-7;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.visual-footnote-body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.visual-footnote-ref {
|
||||
font-family: monospace;
|
||||
font-size: 0.75em;
|
||||
color: $primary;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* An abbreviation definition: a declaration rather than prose, so it is drawn as a form. */
|
||||
.visual-abbreviation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0.5rem 0;
|
||||
padding: 4px 8px;
|
||||
border: 1px dashed $grey-4;
|
||||
border-radius: 4px;
|
||||
background-color: $grey-1;
|
||||
|
||||
.body--dark & {
|
||||
border-color: $grey-8;
|
||||
background-color: $grey-10;
|
||||
}
|
||||
|
||||
input {
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px;
|
||||
font-size: 0.85rem;
|
||||
|
||||
&:hover {
|
||||
border-color: $grey-4;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: $primary;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.body--dark &:focus {
|
||||
background: $grey-9;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.visual-abbreviation-label {
|
||||
flex: 0 0 auto;
|
||||
width: 8rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.visual-abbreviation-title {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
/* A task item, drawn with the checkbox the source says it has. */
|
||||
.visual-task-item {
|
||||
list-style: none;
|
||||
position: relative;
|
||||
|
||||
&:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -1.4em;
|
||||
top: 0.35em;
|
||||
width: 0.9em;
|
||||
height: 0.9em;
|
||||
border: 1px solid $grey-6;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&[data-checked='true']:before {
|
||||
content: '✓';
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75em;
|
||||
line-height: 1;
|
||||
color: #fff;
|
||||
background-color: $primary;
|
||||
border-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.visual-hr {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.visual-icon {
|
||||
vertical-align: -0.125em;
|
||||
}
|
||||
|
||||
.visual-raw-inline {
|
||||
font-family: monospace;
|
||||
font-size: 0.85em;
|
||||
padding: 0 3px;
|
||||
border-radius: 3px;
|
||||
background-color: $grey-3;
|
||||
|
||||
.body--dark & {
|
||||
background-color: $grey-9;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* The floating bars the editor hangs off things that have no furniture of their own.
|
||||
*
|
||||
* A block node gets a header from its node view; a link mark and an image do not — one is a mark and
|
||||
* the other is a bare `<img>` — so each has a plugin that draws a small bar against the thing the
|
||||
* selection is on. What the two share is the button, which is where the one non-obvious rule lives.
|
||||
*/
|
||||
|
||||
/** One button on a bar. */
|
||||
export function barButton(label, onClick) {
|
||||
const button = document.createElement('button')
|
||||
button.type = 'button'
|
||||
button.className = 'visual-node-action'
|
||||
button.textContent = label
|
||||
/*
|
||||
The bar sits outside the editable, so clicking it would otherwise blur the editor and collapse the
|
||||
selection this whole thing is keyed on -- taking the bar away before the click landed.
|
||||
*/
|
||||
button.addEventListener('mousedown', (event) => event.preventDefault())
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault()
|
||||
onClick()
|
||||
})
|
||||
return button
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* The fenced source a block component carries, read out and written back.
|
||||
*
|
||||
* The counterpart of `helpers/markdownBlocks.js`, which does this by line number over a whole page's
|
||||
* text. Here the block is a node and its body is a string of its own, so there is nothing to locate —
|
||||
* only the fence to take off and put back. Kept apart from the schema and the parser because it is
|
||||
* about what is INSIDE a block, which is the block's business rather than the document's.
|
||||
*/
|
||||
|
||||
/** A fenced block: the opening fence and its info string, the body, and the closing fence. */
|
||||
const FENCED = /^ {0,3}(`{3,}|~{3,})[ \t]*([^\s`]*)[^\n]*\n([\s\S]*?)\n? {0,3}\1[ \t]*$/
|
||||
|
||||
/**
|
||||
* @param {string} body A block's body, as stored on the node.
|
||||
* @returns {?{ lang: string, source: string }} Null when the body is not a single fenced source,
|
||||
* which is what a block with a content editor always holds — see `bodyIsContent` in
|
||||
* `parse.js`, which is the rule that decided this block is an atom in the first place.
|
||||
*/
|
||||
export function readFencedBody(body) {
|
||||
const match = FENCED.exec((body ?? '').trim())
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
return { lang: match[2] ?? '', source: match[3] ?? '' }
|
||||
}
|
||||
|
||||
/**
|
||||
* The same body with a new source in it, fenced the way it was.
|
||||
*
|
||||
* The fence is grown past anything in the source that would close it early, exactly as the
|
||||
* serialiser does for a code block: a draw.io diagram's XML is not going to contain a run of
|
||||
* backticks, but a source somebody pasted might.
|
||||
*
|
||||
* @param {string} body The body as it stands, for the language and fence style to keep.
|
||||
* @param {string} source The new source.
|
||||
*/
|
||||
export function writeFencedBody(body, source) {
|
||||
const existing = readFencedBody(body)
|
||||
const lang = existing?.lang ?? ''
|
||||
const runs = source.match(/`{3,}/gm)
|
||||
const fence = runs ? `${runs.sort().at(-1)}\`` : '```'
|
||||
return `${fence}${lang}\n${source}\n${fence}`
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import * as Y from 'yjs'
|
||||
import {
|
||||
prosemirrorToYXmlFragment,
|
||||
redo as yRedo,
|
||||
undo as yUndo,
|
||||
yCursorPlugin,
|
||||
ySyncPlugin,
|
||||
yUndoPlugin
|
||||
} from 'y-prosemirror'
|
||||
|
||||
/**
|
||||
* Collaborative editing for the Visual editor.
|
||||
*
|
||||
* The Markdown editor shares a `Y.Text` of the page source, which is what a text editor binds to.
|
||||
* ProseMirror cannot: a document is a tree, and y-prosemirror shares it as a `Y.XmlFragment`. So the
|
||||
* two editors collaborate through different shared types in the same room — which is not a problem to
|
||||
* solve, because a page has exactly one editor and everybody who opens it gets that one. Two people
|
||||
* are never in the same room through different editors.
|
||||
*
|
||||
* The hard part is SEEDING, and it is worth reading `core/collab.ts` on this first. A room that
|
||||
* nobody is already in has to be filled from the page, and a Yjs document cannot simply be filled
|
||||
* twice: two instances doing it at once would produce two copies of the page. The server solves that
|
||||
* by making its seed deterministic — built in a scratch document pinned to a fixed client id, so the
|
||||
* bytes depend on nothing but the content — and two identical seeds merge to one document.
|
||||
*
|
||||
* The server cannot build this one. The fragment is a ProseMirror document, and turning markdown into
|
||||
* one means the whole parser, which lives in the browser and is deliberately not on the server. So the
|
||||
* seed is built HERE, by whichever clients arrive first — using the very same trick, for the very same
|
||||
* reason, under a client id of its own. See `SEED_CLIENT_ID`, which is where that gets interesting.
|
||||
*/
|
||||
|
||||
/** The shared type the document lives in. Named, because the room also holds the Markdown editor's. */
|
||||
const FRAGMENT = 'prosemirror'
|
||||
|
||||
export function collabFragment(ydoc) {
|
||||
return ydoc.getXmlFragment(FRAGMENT)
|
||||
}
|
||||
|
||||
/**
|
||||
* The client id this seed is written under.
|
||||
*
|
||||
* Pinned, so the bytes are a function of the document and nothing else — that is what lets two
|
||||
* browsers seed the same page at the same instant and produce one document rather than two.
|
||||
*
|
||||
* NOT zero, and that is the whole of why this constant exists. `buildSeed` in `core/collab.ts` pins
|
||||
* ITS seed to client 0, and a Yjs update is identified by its client id and a clock counting from
|
||||
* there — so a second seed claiming client 0 describes changes the document has already seen, and Yjs
|
||||
* discards it in silence. The room ended up holding the server's `content` text and an empty
|
||||
* fragment, which reached the author as a page that opened blank and then saved over itself.
|
||||
*
|
||||
* Any id but the server's will do; 1 is simply the next one. A real client picks a random 32-bit id,
|
||||
* so the chance of meeting either of these is the same vanishing chance the server already lives with.
|
||||
*/
|
||||
const SEED_CLIENT_ID = 1
|
||||
|
||||
/**
|
||||
* The room's starting state, as a Yjs update.
|
||||
*
|
||||
* Deterministic, for the reason `SEED_CLIENT_ID` gives.
|
||||
*/
|
||||
export function buildSeed(doc) {
|
||||
const scratch = new Y.Doc()
|
||||
scratch.clientID = SEED_CLIENT_ID
|
||||
prosemirrorToYXmlFragment(doc, scratch.getXmlFragment(FRAGMENT))
|
||||
const update = Y.encodeStateAsUpdate(scratch)
|
||||
scratch.destroy()
|
||||
return update
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill the room from this browser's copy of the page, if nobody has filled it yet.
|
||||
*
|
||||
* Asked once the document has synced, so "empty" means the room genuinely holds no document rather
|
||||
* than that this browser has not heard about it yet. A room somebody is already in is left alone: what
|
||||
* it holds is the current state of the edit, including anything unsaved, and it outranks whatever this
|
||||
* browser loaded from the API.
|
||||
*
|
||||
* @returns {boolean} Whether this call was the one that seeded it.
|
||||
*/
|
||||
export function seedIfEmpty(ydoc, doc) {
|
||||
const fragment = collabFragment(ydoc)
|
||||
if (fragment.length > 0) {
|
||||
return false
|
||||
}
|
||||
Y.applyUpdate(ydoc, buildSeed(doc))
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugins that replace `history()` once the editor is collaborating.
|
||||
*
|
||||
* Undo has to come from Yjs rather than from ProseMirror: `prosemirror-history` would happily undo
|
||||
* whatever changed last, which in a shared document is very often somebody else's sentence. Yjs tracks
|
||||
* what THIS client did.
|
||||
*
|
||||
* The editor's state is created with NO document and `ySyncPlugin` fills it from the fragment. The
|
||||
* alternative — building the document with `initProseMirrorDoc` and handing the plugin its mapping —
|
||||
* reads better and is what the newer examples show, but here it left the binding and the document
|
||||
* disagreeing: the first click emptied the page. Letting the plugin be the one to produce the
|
||||
* document keeps the two in step by construction.
|
||||
*/
|
||||
export function collabPlugins({ ydoc, awareness }) {
|
||||
return [ySyncPlugin(collabFragment(ydoc)), yCursorPlugin(awareness), yUndoPlugin()]
|
||||
}
|
||||
|
||||
/** Undo and redo, as the collaborative document means them. */
|
||||
export const collabUndo = yUndo
|
||||
export const collabRedo = yRedo
|
||||
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
baseKeymap,
|
||||
chainCommands,
|
||||
exitCode,
|
||||
lift,
|
||||
setBlockType,
|
||||
toggleMark,
|
||||
wrapIn
|
||||
} from 'prosemirror-commands'
|
||||
import { redo, undo } from 'prosemirror-history'
|
||||
import {
|
||||
InputRule,
|
||||
inputRules,
|
||||
smartQuotes,
|
||||
textblockTypeInputRule,
|
||||
wrappingInputRule
|
||||
} from 'prosemirror-inputrules'
|
||||
import { undoInputRule } from 'prosemirror-inputrules'
|
||||
import { liftListItem, sinkListItem, splitListItem, wrapInList } from 'prosemirror-schema-list'
|
||||
import { TextSelection } from 'prosemirror-state'
|
||||
import { goToNextCell } from 'prosemirror-tables'
|
||||
|
||||
import { collabRedo, collabUndo } from './collab'
|
||||
import { schema } from './schema'
|
||||
|
||||
/**
|
||||
* What the keyboard and the toolbar do.
|
||||
*
|
||||
* Two audiences at once, and the tension between them is the whole design. Somebody who reached for
|
||||
* the Visual editor because they do not write markdown needs the toolbar to be the whole story;
|
||||
* somebody who does write markdown will type `## ` and `- ` out of habit and be disappointed when
|
||||
* nothing happens. So the input rules are the markdown they would have typed, and they produce the
|
||||
* same document the toolbar produces.
|
||||
*/
|
||||
|
||||
/** `**bold**`, and the rest of the paired inline markup, applied as the closing marker is typed. */
|
||||
function markInputRule(pattern, markType, getAttrs) {
|
||||
return new InputRule(pattern, (state, match, start, end) => {
|
||||
const attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs
|
||||
const text = match[1]
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
const tr = state.tr
|
||||
/*
|
||||
The whole match goes, markers included, and the mark is applied to what was between them —
|
||||
rather than leaving the markers in the text, which is what a source editor would do and is
|
||||
exactly the thing this editor exists not to show anybody.
|
||||
*/
|
||||
tr.replaceWith(start, end, schema.text(text, [markType.create(attrs)]))
|
||||
// -> Stored marks cleared, so the next character typed is outside the mark that was just closed
|
||||
tr.removeStoredMark(markType)
|
||||
return tr
|
||||
})
|
||||
}
|
||||
|
||||
export function buildInputRules() {
|
||||
const rules = [
|
||||
...smartQuotes,
|
||||
|
||||
// -> `1. `, `- `, `* `, `+ `
|
||||
wrappingInputRule(
|
||||
/^(\d+)([.)])\s$/,
|
||||
schema.nodes.ordered_list,
|
||||
(match) => ({ order: Number(match[1]), markup: match[2] }),
|
||||
(match, node) => node.childCount + node.attrs.order === Number(match[1])
|
||||
),
|
||||
wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list, (match) => ({
|
||||
markup: match[1]
|
||||
})),
|
||||
|
||||
// -> `> `
|
||||
wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote),
|
||||
|
||||
// -> `# ` through `###### `
|
||||
textblockTypeInputRule(/^(#{1,6})\s$/, schema.nodes.heading, (match) => ({
|
||||
level: match[1].length
|
||||
})),
|
||||
|
||||
// -> ``` or ~~~, with whatever info string was typed after it
|
||||
textblockTypeInputRule(/^(?:```|~~~)([^\s`]*)\s$/, schema.nodes.code_block, (match) => ({
|
||||
params: match[1] ?? ''
|
||||
})),
|
||||
|
||||
markInputRule(/(?:\*\*)([^*]+)(?:\*\*)$/, schema.marks.strong),
|
||||
markInputRule(/(?:^|[^*])\*([^*]+)\*$/, schema.marks.em),
|
||||
markInputRule(/(?:~~)([^~]+)(?:~~)$/, schema.marks.strike),
|
||||
markInputRule(/(?:==)([^=]+)(?:==)$/, schema.marks.mark),
|
||||
markInputRule(/(?:`)([^`]+)(?:`)$/, schema.marks.code),
|
||||
|
||||
/*
|
||||
`---` on a line of its own. Written as a rule rather than left to the parser because there is no
|
||||
parser here: what an author types goes straight into the document.
|
||||
*/
|
||||
new InputRule(/^(?:---|\*\*\*|___)$/, (state, match, start, end) =>
|
||||
state.tr.replaceWith(start, end, schema.nodes.horizontal_rule.create({ markup: match[0] }))
|
||||
),
|
||||
|
||||
/*
|
||||
`[!NOTE] ` at the start of a blockquote, which is how the alert is written in markdown — so an
|
||||
author who knows the syntax gets the alert rather than a quote that says `[!NOTE]`.
|
||||
*/
|
||||
new InputRule(/^\[!(note|tip|important|warning|caution)\]\s$/i, (state, match, start, end) => {
|
||||
const { $from } = state.selection
|
||||
const tr = state.tr.delete(start, end)
|
||||
const range = tr.selection.$from.blockRange()
|
||||
if (!range) {
|
||||
return null
|
||||
}
|
||||
const kind = match[1].toLowerCase()
|
||||
if ($from.node(-1).type === schema.nodes.blockquote) {
|
||||
// -> Already inside a quote: the quote IS the alert, so it changes type in place
|
||||
tr.setNodeMarkup($from.before(-1), schema.nodes.alert, { kind, title: null })
|
||||
return tr
|
||||
}
|
||||
tr.wrap(range, [{ type: schema.nodes.alert, attrs: { kind, title: null } }])
|
||||
return tr
|
||||
})
|
||||
]
|
||||
return inputRules({ rules })
|
||||
}
|
||||
|
||||
/** Put the caret in a sensible place after a node has been inserted at `pos`. */
|
||||
function selectInside(tr, pos) {
|
||||
const node = tr.doc.nodeAt(pos)
|
||||
if (!node) {
|
||||
return tr
|
||||
}
|
||||
if (node.isAtom || !node.isTextblock) {
|
||||
const inner = tr.doc.resolve(pos + 1)
|
||||
return tr.setSelection(TextSelection.near(inner))
|
||||
}
|
||||
return tr.setSelection(TextSelection.near(tr.doc.resolve(pos + 1)))
|
||||
}
|
||||
|
||||
/** Toggle a textblock between a heading of some level and a plain paragraph. */
|
||||
export function toggleHeading(level) {
|
||||
return (state, dispatch) => {
|
||||
const { $from } = state.selection
|
||||
const isSame = $from.parent.type === schema.nodes.heading && $from.parent.attrs.level === level
|
||||
const type = isSame ? schema.nodes.paragraph : schema.nodes.heading
|
||||
const attrs = isSame
|
||||
? { mdAttrs: $from.parent.attrs.mdAttrs }
|
||||
: { level, mdAttrs: $from.parent.attrs.mdAttrs }
|
||||
return setBlockType(type, attrs)(state, dispatch)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn the list the caret is in into a task list, or back.
|
||||
*
|
||||
* A task is an attribute of each item rather than a kind of list, because that is what it is in the
|
||||
* source — so this walks the items of the list rather than changing the list's type.
|
||||
*/
|
||||
export function toggleTaskList(state, dispatch) {
|
||||
const { $from } = state.selection
|
||||
for (let depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth)
|
||||
if (node.type !== schema.nodes.bullet_list) {
|
||||
continue
|
||||
}
|
||||
if (dispatch) {
|
||||
const start = $from.before(depth)
|
||||
const tr = state.tr
|
||||
const makeTasks = node.firstChild?.attrs.checked === null
|
||||
let offset = start + 1
|
||||
node.forEach((item) => {
|
||||
tr.setNodeMarkup(offset, undefined, { ...item.attrs, checked: makeTasks ? false : null })
|
||||
offset += item.nodeSize
|
||||
})
|
||||
dispatch(tr)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Tick or untick the task item the caret is in. */
|
||||
export function toggleTaskChecked(state, dispatch) {
|
||||
const { $from } = state.selection
|
||||
for (let depth = $from.depth; depth > 0; depth--) {
|
||||
const node = $from.node(depth)
|
||||
if (node.type !== schema.nodes.list_item || node.attrs.checked === null) {
|
||||
continue
|
||||
}
|
||||
if (dispatch) {
|
||||
dispatch(
|
||||
state.tr.setNodeMarkup($from.before(depth), undefined, {
|
||||
...node.attrs,
|
||||
checked: !node.attrs.checked
|
||||
})
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/** Wrap the selection in an alert of the given kind, or unwrap one it is already in. */
|
||||
export function toggleAlert(kind) {
|
||||
return (state, dispatch) => {
|
||||
const { $from } = state.selection
|
||||
for (let depth = $from.depth; depth > 0; depth--) {
|
||||
if ($from.node(depth).type === schema.nodes.alert) {
|
||||
if ($from.node(depth).attrs.kind !== kind) {
|
||||
if (dispatch) {
|
||||
dispatch(
|
||||
state.tr.setNodeMarkup($from.before(depth), undefined, {
|
||||
...$from.node(depth).attrs,
|
||||
kind
|
||||
})
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return lift(state, dispatch)
|
||||
}
|
||||
}
|
||||
return wrapIn(schema.nodes.alert, { kind, title: null })(state, dispatch)
|
||||
}
|
||||
}
|
||||
|
||||
/** Insert a node at the caret, replacing whatever is selected. */
|
||||
export function insertNode(type, attrs = {}, content = null) {
|
||||
return (state, dispatch) => {
|
||||
const node = type.createAndFill(attrs, content)
|
||||
if (!node) {
|
||||
return false
|
||||
}
|
||||
if (dispatch) {
|
||||
const tr = state.tr.replaceSelectionWith(node)
|
||||
const pos = tr.selection.$from.pos - node.nodeSize
|
||||
dispatch(selectInside(tr, pos).scrollIntoView())
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** A table of `rows` × `cols`, its first row a header. */
|
||||
export function insertTable(rows = 3, cols = 3) {
|
||||
return (state, dispatch) => {
|
||||
const cell = (type) => type.createAndFill()
|
||||
const body = []
|
||||
for (let r = 0; r < rows; r++) {
|
||||
const cells = []
|
||||
for (let c = 0; c < cols; c++) {
|
||||
cells.push(cell(r === 0 ? schema.nodes.table_header : schema.nodes.table_cell))
|
||||
}
|
||||
body.push(schema.nodes.table_row.createAndFill(null, cells))
|
||||
}
|
||||
return insertNode(schema.nodes.table, null, body)(state, dispatch)
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a mark is on the selection, or would be on what is typed next. */
|
||||
export function markActive(state, type) {
|
||||
const { from, $from, to, empty } = state.selection
|
||||
return empty
|
||||
? Boolean(type.isInSet(state.storedMarks || $from.marks()))
|
||||
: state.doc.rangeHasMark(from, to, type)
|
||||
}
|
||||
|
||||
/** Whether the caret sits anywhere inside a node of this type. */
|
||||
export function nodeActive(state, type, attrs = null) {
|
||||
const { $from } = state.selection
|
||||
for (let depth = $from.depth; depth >= 0; depth--) {
|
||||
const node = $from.node(depth)
|
||||
if (node.type !== type) {
|
||||
continue
|
||||
}
|
||||
if (!attrs) {
|
||||
return true
|
||||
}
|
||||
return Object.entries(attrs).every(([name, value]) => node.attrs[name] === value)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything bound to a key.
|
||||
*
|
||||
* Mod is Cmd on a Mac and Ctrl everywhere else. The set is deliberately close to what the Markdown
|
||||
* editor binds, so that somebody moving between the two does not have to relearn anything.
|
||||
*/
|
||||
export function buildKeymap({ collab = false } = {}) {
|
||||
/*
|
||||
Undo comes from Yjs while collaborating. `prosemirror-history` undoes whatever changed last, which
|
||||
in a shared document is very often the sentence somebody else is in the middle of typing; Yjs
|
||||
tracks what THIS client did and takes back only that.
|
||||
*/
|
||||
const stepBack = collab ? collabUndo : undo
|
||||
const stepForward = collab ? collabRedo : redo
|
||||
|
||||
const keys = {
|
||||
'Mod-z': stepBack,
|
||||
'Shift-Mod-z': stepForward,
|
||||
'Mod-y': stepForward,
|
||||
Backspace: undoInputRule,
|
||||
|
||||
'Mod-b': toggleMark(schema.marks.strong),
|
||||
'Mod-i': toggleMark(schema.marks.em),
|
||||
'Mod-u': toggleMark(schema.marks.underline),
|
||||
'Mod-Shift-x': toggleMark(schema.marks.strike),
|
||||
'Mod-Shift-h': toggleMark(schema.marks.mark),
|
||||
'Mod-e': toggleMark(schema.marks.code),
|
||||
|
||||
'Mod-Shift-8': wrapInList(schema.nodes.bullet_list),
|
||||
'Mod-Shift-9': wrapInList(schema.nodes.ordered_list),
|
||||
'Mod-Shift-.': wrapIn(schema.nodes.blockquote),
|
||||
'Mod-Shift-Enter': toggleTaskChecked,
|
||||
|
||||
Enter: chainCommands(splitListItem(schema.nodes.list_item), baseKeymap.Enter),
|
||||
Tab: chainCommands(goToNextCell(1), sinkListItem(schema.nodes.list_item)),
|
||||
'Shift-Tab': chainCommands(goToNextCell(-1), liftListItem(schema.nodes.list_item)),
|
||||
|
||||
/*
|
||||
A hard break, which in markdown is the backslash form. Shift-Enter is what every editor binds it
|
||||
to, and `exitCode` first so that the same key gets out of a code block rather than putting a
|
||||
newline in one.
|
||||
*/
|
||||
'Shift-Enter': chainCommands(exitCode, (state, dispatch) => {
|
||||
if (dispatch) {
|
||||
dispatch(
|
||||
state.tr
|
||||
.replaceSelectionWith(schema.nodes.hard_break.create({ markup: 'hard' }))
|
||||
.scrollIntoView()
|
||||
)
|
||||
}
|
||||
return true
|
||||
}),
|
||||
|
||||
// -> Out of a code block or a block that ends the document, where there is otherwise no way back
|
||||
'Mod-Enter': exitCode
|
||||
}
|
||||
|
||||
for (let level = 1; level <= 6; level++) {
|
||||
keys[`Mod-Shift-${level}`] = toggleHeading(level)
|
||||
}
|
||||
|
||||
return { ...baseKeymap, ...keys }
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Moving a page between the Markdown and Visual editors.
|
||||
*
|
||||
* Both write markdown, so there is nothing to translate: a conversion changes which editor opens the
|
||||
* page and nothing about what it says. What it does change is the SPELLING of the source, because the
|
||||
* Visual editor has a document model and markdown has more than one way to write most things in it —
|
||||
* so the file comes back with its lists indented one way, its tables padded one way and its emphasis
|
||||
* written with one character.
|
||||
*
|
||||
* That rewrite lands at conversion time, on purpose. Deferred to the first save it would arrive mixed
|
||||
* into somebody's real edit, and the diff of "fixed a typo" would be the whole file. Done here it is
|
||||
* one history version that says what it is.
|
||||
*
|
||||
* `verifyRoundTrip` is the safety net, and the reason converting can be offered at all. Rather than
|
||||
* trusting the serialiser to be correct, the rewritten source is rendered and compared with the
|
||||
* render of the original: if the page would come out differently, the author is told before anything
|
||||
* is written, with the difference in hand. A serialiser bug becomes a refused conversion instead of a
|
||||
* damaged page.
|
||||
*/
|
||||
|
||||
/** Attributes the renderer stamps for the preview pane, which `models/rendering.ts` strips on save. */
|
||||
const PREVIEW_ARTIFACTS = /\s*data-line="\d+"/g
|
||||
|
||||
/**
|
||||
* A render reduced to what it actually says.
|
||||
*
|
||||
* The preview's line markers go, because they are scaffolding rather than content and the server
|
||||
* removes them before storing anything. Whitespace between tags is collapsed for the same reason: the
|
||||
* serialiser may put a construct on a different line from where the author had it without changing a
|
||||
* word of the page.
|
||||
*/
|
||||
function meaningOf(html) {
|
||||
return html
|
||||
.replace(PREVIEW_ARTIFACTS, '')
|
||||
.replace(/\bclass="line"/g, '')
|
||||
.replace(/\bline\b ?/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* The first place two renders stop agreeing, as a short excerpt of each.
|
||||
*
|
||||
* For telling an author what would change. A character offset into minified HTML is no use to
|
||||
* anybody, so what comes back is the neighbourhood of the difference in both versions.
|
||||
*/
|
||||
function firstDifference(before, after) {
|
||||
let at = 0
|
||||
while (at < before.length && at < after.length && before[at] === after[at]) {
|
||||
at++
|
||||
}
|
||||
const from = Math.max(0, at - 60)
|
||||
return {
|
||||
before: before.slice(from, at + 120).trim(),
|
||||
after: after.slice(from, at + 120).trim()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrite a page's source the way the Visual editor writes it, and check the page still renders the
|
||||
* same.
|
||||
*
|
||||
* @param {string} content The page source as it stands.
|
||||
* @param {object} deps
|
||||
* @param {{ parse: (md: string, path: string) => object }} deps.parser From `createParser`.
|
||||
* @param {(doc: object) => string} deps.serialize From `serialize`.
|
||||
* @param {{ render: (md: string, env: object) => string }} deps.renderer The site's renderer.
|
||||
* @param {string} deps.pagePath The page's path, which a relative image resolves against.
|
||||
* @returns {{ content: string, render: string, changed: boolean, identical: boolean,
|
||||
* difference: ?{ before: string, after: string }, error: ?string }}
|
||||
* `identical` is the one to gate on: it says the page renders the same afterwards.
|
||||
* `changed` only says the source text is not byte-for-byte what it was, which is expected
|
||||
* and harmless.
|
||||
*/
|
||||
export function normalizeForVisual(content, { parser, serialize, renderer, pagePath = '' }) {
|
||||
const source = content ?? ''
|
||||
let rewritten
|
||||
try {
|
||||
rewritten = serialize(parser.parse(source, pagePath))
|
||||
} catch (err) {
|
||||
return {
|
||||
content: source,
|
||||
render: '',
|
||||
changed: false,
|
||||
identical: false,
|
||||
difference: null,
|
||||
error: err.message
|
||||
}
|
||||
}
|
||||
|
||||
let before
|
||||
let after
|
||||
try {
|
||||
before = renderer.render(source, { pagePath })
|
||||
after = renderer.render(rewritten, { pagePath })
|
||||
} catch (err) {
|
||||
return {
|
||||
content: rewritten,
|
||||
render: '',
|
||||
changed: rewritten !== source,
|
||||
identical: false,
|
||||
difference: null,
|
||||
error: err.message
|
||||
}
|
||||
}
|
||||
|
||||
const identical = meaningOf(before) === meaningOf(after)
|
||||
return {
|
||||
content: rewritten,
|
||||
render: after,
|
||||
changed: rewritten !== source,
|
||||
identical,
|
||||
difference: identical ? null : firstDifference(meaningOf(before), meaningOf(after)),
|
||||
error: null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What a conversion has to send, for either direction.
|
||||
*
|
||||
* Converting TO the Visual editor rewrites the source; converting away from it does not, because the
|
||||
* source is already the form the Visual editor writes — every markdown editor reads it unchanged. So
|
||||
* the other direction sends nothing but the editor, and the page's content column is never touched.
|
||||
*/
|
||||
export function conversionPayload({ toEditor, normalized, reasonForChange }) {
|
||||
if (toEditor !== 'visual') {
|
||||
return { editor: toEditor, reasonForChange }
|
||||
}
|
||||
return {
|
||||
editor: toEditor,
|
||||
...(normalized.changed ? { content: normalized.content, render: normalized.render } : {}),
|
||||
reasonForChange
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { NodeSelection, Plugin } from 'prosemirror-state'
|
||||
|
||||
import { barButton } from './bar'
|
||||
import { schema } from './schema'
|
||||
|
||||
/**
|
||||
* Editing an image that is already in the page.
|
||||
*
|
||||
* An image is a leaf node, so clicking it selects it and there is nothing else to do with it: no
|
||||
* caret goes inside an `<img>`, and the three things an author wants — a different size, a different
|
||||
* picture, no picture — are all attributes of a node that offers no way to reach them. Retyping was
|
||||
* the only route, and that means knowing the `=WxH` suffix by heart.
|
||||
*
|
||||
* So this is the same shape as `links.js`: a small bar drawn against the selected image, and a plugin
|
||||
* rather than part of the node view because the question it answers — what is selected — is about the
|
||||
* document and has to be re-asked on every transaction. The node view draws the picture; this draws
|
||||
* what can be done to it.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The image the selection is on.
|
||||
*
|
||||
* A `NodeSelection` and not "an image somewhere near the caret": clicking a leaf node is exactly what
|
||||
* makes one, so this is the selection an author produces by pointing at the picture, and nothing else
|
||||
* brings the bar up.
|
||||
*
|
||||
* @returns {?{ pos: number, node: import('prosemirror-model').Node }} Null when the selection is not
|
||||
* an image.
|
||||
*/
|
||||
export function findImage(state) {
|
||||
const { selection } = state
|
||||
if (!(selection instanceof NodeSelection) || selection.node.type !== schema.nodes.image) {
|
||||
return null
|
||||
}
|
||||
return { pos: selection.from, node: selection.node }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write attributes back onto the image at a position.
|
||||
*
|
||||
* Everything not named is kept, so resizing a picture does not drop its alt text and replacing it
|
||||
* does not drop a size that was set on it. The image is re-selected afterwards: the bar is keyed on
|
||||
* the selection, and `setNodeMarkup` leaves it pointing at a node that no longer exists, so without
|
||||
* this the bar disappears the moment it is used.
|
||||
*/
|
||||
function setImageAttrs(view, pos, attrs) {
|
||||
const node = view.state.doc.nodeAt(pos)
|
||||
if (!node || node.type !== schema.nodes.image) {
|
||||
return false
|
||||
}
|
||||
const tr = view.state.tr.setNodeMarkup(pos, null, { ...node.attrs, ...attrs }, node.marks)
|
||||
tr.setSelection(NodeSelection.create(tr.doc, pos))
|
||||
view.dispatch(tr)
|
||||
view.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* What the image dialog edits: the text that stands in for the picture, and how big it is drawn.
|
||||
*
|
||||
* The dimensions are strings rather than numbers because `markdown-it-imsize` accepts a percentage as
|
||||
* well as a pixel count, and an empty one becomes null rather than `''` so that the serialiser can
|
||||
* tell "no width" from a width of nothing. The alt text does the same: empty is a deliberate value —
|
||||
* a picture that carries no meaning of its own, which a screen reader should skip rather than read
|
||||
* a file name out of — and null is how the node says it has none.
|
||||
*/
|
||||
export function applyImageEdit(view, pos, { alt, width, height }) {
|
||||
return setImageAttrs(view, pos, {
|
||||
alt: alt || null,
|
||||
width: width || null,
|
||||
height: height || null
|
||||
})
|
||||
}
|
||||
|
||||
/** Point an image at a different file, keeping its size and everything else about it. */
|
||||
export function applyImageSrc(view, pos, { src, alt }) {
|
||||
return setImageAttrs(view, pos, { src, ...(alt ? { alt } : {}) })
|
||||
}
|
||||
|
||||
/** Take the image out of the page. */
|
||||
export function removeImage(view, pos) {
|
||||
const node = view.state.doc.nodeAt(pos)
|
||||
if (!node || node.type !== schema.nodes.image) {
|
||||
return false
|
||||
}
|
||||
view.dispatch(view.state.tr.delete(pos, pos + node.nodeSize))
|
||||
view.focus()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The bar itself.
|
||||
*
|
||||
* @param {object} handlers
|
||||
* @param {(target: object) => void} handlers.onEdit Open whatever sets the alt text and the size.
|
||||
* @param {(target: object) => void} handlers.onReplace Pick a different file.
|
||||
* @param {(target: object) => void} handlers.onRemove Delete the image.
|
||||
* @param {(key: string) => string} handlers.t
|
||||
*/
|
||||
export function imageBar({ onEdit, onReplace, onRemove, t }) {
|
||||
return new Plugin({
|
||||
view(editorView) {
|
||||
const dom = document.createElement('div')
|
||||
dom.className = 'visual-image-bar'
|
||||
dom.hidden = true
|
||||
|
||||
let target = null
|
||||
/*
|
||||
Read by the `load` listener below, which outlives the plugin: asking a destroyed view for a
|
||||
node's DOM throws.
|
||||
*/
|
||||
let destroyed = false
|
||||
dom.append(
|
||||
barButton(t('editor.visual.image.edit'), () => target && onEdit(target)),
|
||||
barButton(t('editor.visual.image.replace'), () => target && onReplace(target)),
|
||||
barButton(t('editor.visual.image.remove'), () => target && onRemove(target))
|
||||
)
|
||||
|
||||
// -> Into the element ProseMirror was mounted into, for the reason `links.js` gives
|
||||
const surface = editorView.dom.parentNode
|
||||
surface.append(dom)
|
||||
|
||||
const update = (view) => {
|
||||
target = view.editable ? findImage(view.state) : null
|
||||
const el = target && view.nodeDOM(target.pos)
|
||||
if (!el?.getBoundingClientRect) {
|
||||
dom.hidden = true
|
||||
return
|
||||
}
|
||||
|
||||
/*
|
||||
Against the image's own box rather than a document position, which is what puts the bar over
|
||||
the top-left corner of the picture however tall it is.
|
||||
*/
|
||||
/*
|
||||
A picture that has not loaded yet has the empty box a browser gives an image with no
|
||||
intrinsic size, and so would leave the bar somewhere the image is not. One listener, which
|
||||
re-runs this once the real box exists — and which also covers the picture being REPLACED,
|
||||
where the new file's size arrives after the transaction that pointed at it.
|
||||
*/
|
||||
if (el instanceof HTMLImageElement && !el.complete) {
|
||||
el.addEventListener('load', () => !destroyed && update(view), { once: true })
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
const box = surface.getBoundingClientRect()
|
||||
dom.hidden = false
|
||||
/*
|
||||
Above the image, and below it when there is no room above -- an image at the very top of the
|
||||
document has only the editable's own padding over it, which is less than the bar is tall.
|
||||
Measured after unhiding, since a hidden element has no height.
|
||||
*/
|
||||
const above = rect.top - box.top - dom.offsetHeight - 6
|
||||
dom.style.top = `${above >= 0 ? above : rect.bottom - box.top + 6}px`
|
||||
dom.style.left = `${Math.max(0, rect.left - box.left)}px`
|
||||
}
|
||||
|
||||
update(editorView)
|
||||
return {
|
||||
update,
|
||||
destroy: () => {
|
||||
destroyed = true
|
||||
dom.remove()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { gapCursor } from 'prosemirror-gapcursor'
|
||||
import { history, redo, undo } from 'prosemirror-history'
|
||||
import { keymap } from 'prosemirror-keymap'
|
||||
import { EditorState } from 'prosemirror-state'
|
||||
import { columnResizing, tableEditing } from 'prosemirror-tables'
|
||||
import { EditorView } from 'prosemirror-view'
|
||||
|
||||
import { MarkdownRenderer } from '@/renderers/markdown'
|
||||
|
||||
import { collabPlugins, collabRedo, collabUndo, seedIfEmpty } from './collab'
|
||||
import { buildInputRules, buildKeymap } from './commands'
|
||||
import { imageBar } from './images'
|
||||
import { linkBar } from './links'
|
||||
import { createNodeViews } from './nodeviews'
|
||||
import { createParser } from './parse'
|
||||
import { schema } from './schema'
|
||||
import { serialize } from './serialize'
|
||||
|
||||
/**
|
||||
* The Visual editor, assembled.
|
||||
*
|
||||
* One object holding the three things that have to agree with each other — the renderer, the parser
|
||||
* built on it, and the serialiser — so that nothing else in the app can accidentally pair a parser
|
||||
* with a renderer configured differently. `EditorVisual.vue` builds exactly one of these and talks to
|
||||
* it; everything below this line is free of Vue.
|
||||
*/
|
||||
|
||||
export { schema, serialize }
|
||||
|
||||
/**
|
||||
* @param {object} options
|
||||
* @param {HTMLElement} options.mount Where the editor's DOM goes.
|
||||
* @param {string} options.content The page source to open on.
|
||||
* @param {object} options.config The site's markdown editor config — the same one the preview uses,
|
||||
* because the parse and the render have to be the same parse and the same render.
|
||||
* @param {object} options.context What the node views need from the app; see `createNodeViews`.
|
||||
* @param {() => void} options.onChange Called after every change to the document. Deliberately given
|
||||
* nothing: the source and the render are what `getMarkdown` and `getRender` produce, and
|
||||
* producing them is the expensive part the caller is expected to debounce.
|
||||
* @param {() => void} [options.onSelectionChange] Called after every transaction, for the toolbar.
|
||||
*/
|
||||
export function createVisualEditor({
|
||||
mount,
|
||||
content,
|
||||
config,
|
||||
context,
|
||||
onChange,
|
||||
onSelectionChange
|
||||
}) {
|
||||
const renderer = new MarkdownRenderer(config)
|
||||
const parser = createParser(renderer, config)
|
||||
|
||||
const render = (markdown) => renderer.render(markdown, { pagePath: context.pagePath() })
|
||||
|
||||
/*
|
||||
A block's body is rendered through the same pipeline, on its own: the block reads its source out
|
||||
of the markup it is handed, so a diagram's fence has to arrive as the `<pre>` the renderer makes
|
||||
of it rather than as raw text.
|
||||
*/
|
||||
context.renderMarkdown = (markdown) => render(markdown)
|
||||
|
||||
/*
|
||||
Everything but the document. Built as a function because collaborating swaps two of them: the
|
||||
shared document replaces `history`, since Yjs has to be the one deciding what undo means when more
|
||||
than one person is typing — see `collab.js`.
|
||||
*/
|
||||
/** Whether this editor has joined a session, which changes what undo means. */
|
||||
let collaborating = false
|
||||
|
||||
/**
|
||||
* Whether `destroy` has been called, which everything reachable from outside has to ask.
|
||||
*
|
||||
* A destroyed `EditorView` throws on anything that touches its document, and transactions do arrive
|
||||
* after teardown: `y-prosemirror` batches the metadata it sets and flushes it on a microtask, so a
|
||||
* change made in the moment the author left the page is dispatched into a view that no longer
|
||||
* exists. That surfaced as `Cannot read properties of null (reading 'matchesNode')` every time a
|
||||
* collaborative session was closed, and as an uncaught error it took the rest of whatever was
|
||||
* unwinding with it.
|
||||
*/
|
||||
let destroyed = false
|
||||
|
||||
const buildPlugins = (isCollab) => [
|
||||
buildInputRules(),
|
||||
keymap(buildKeymap({ collab: isCollab })),
|
||||
gapCursor(),
|
||||
// -> Before `tableEditing`, which is what its own documentation asks for
|
||||
columnResizing(),
|
||||
tableEditing(),
|
||||
/*
|
||||
The bar that appears under the link the caret is in. A plugin because a link is a mark and has no
|
||||
node view to hang anything off — see `links.js`.
|
||||
*/
|
||||
linkBar({
|
||||
onEdit: (range) => context.editLink(range),
|
||||
onRemove: (range) => context.removeLink(range),
|
||||
t: context.t
|
||||
}),
|
||||
/*
|
||||
And the one that appears over the selected image. A plugin for the same reason, from the other
|
||||
direction: an image IS a node, but a leaf one that nothing can be drawn inside -- see
|
||||
`images.js`.
|
||||
*/
|
||||
imageBar({
|
||||
onEdit: (target) => context.editImage(target),
|
||||
onReplace: (target) => context.replaceImage(target),
|
||||
onRemove: (target) => context.removeNode(target.pos),
|
||||
t: context.t
|
||||
})
|
||||
]
|
||||
|
||||
const view = new EditorView(mount, {
|
||||
state: EditorState.create({
|
||||
doc: parser.parse(content ?? '', context.pagePath()),
|
||||
plugins: [...buildPlugins(false), history()]
|
||||
}),
|
||||
nodeViews: createNodeViews(context),
|
||||
attributes: {
|
||||
class: 'visual-editor-content page-contents',
|
||||
spellcheck: 'true'
|
||||
},
|
||||
dispatchTransaction(transaction) {
|
||||
// -> See `destroyed`: a transaction can outlive the view it was made for
|
||||
if (destroyed) {
|
||||
return
|
||||
}
|
||||
const next = view.state.apply(transaction)
|
||||
view.updateState(next)
|
||||
/*
|
||||
The change is only reported, not serialised: serialising and rendering the whole document is
|
||||
what the caller debounces, and doing it here would be doing it on every keystroke before the
|
||||
debounce ever saw it.
|
||||
*/
|
||||
if (transaction.docChanged) {
|
||||
onChange?.()
|
||||
}
|
||||
// -> Always, and never debounced: this is what the toolbar reads, and a toolbar that lights up
|
||||
// half a second after the caret moved is a toolbar that is wrong half the time
|
||||
onSelectionChange?.()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
view,
|
||||
schema,
|
||||
|
||||
/** The page source as it stands. */
|
||||
getMarkdown: () => serialize(view.state.doc),
|
||||
|
||||
/** The render of that source, which is what a save stores as the page's HTML. */
|
||||
getRender: () => render(serialize(view.state.doc)),
|
||||
|
||||
/**
|
||||
* Undo and redo, as the document currently means them.
|
||||
*
|
||||
* Read at the moment of the click rather than captured, because what they mean changes when the
|
||||
* editor joins a session — see the keymap, which makes the same switch for `Mod-z`.
|
||||
*/
|
||||
undo: () => (collaborating ? collabUndo : undo)(view.state, view.dispatch),
|
||||
redo: () => (collaborating ? collabRedo : redo)(view.state, view.dispatch),
|
||||
|
||||
/**
|
||||
* A snippet of markdown as a fragment ready to be inserted.
|
||||
*
|
||||
* This is what lets the Visual editor reuse every overlay the Markdown editor has — the block
|
||||
* picker, the table editor, the file manager all hand back markdown, and it is parsed by the same
|
||||
* parser that opened the page rather than by a second set of insert paths per construct.
|
||||
*/
|
||||
parseFragment(markdown) {
|
||||
return parser.parse(markdown ?? '', context.pagePath()).content
|
||||
},
|
||||
|
||||
/**
|
||||
* Replace everything, for the cases where the source changed underneath the editor — a pending
|
||||
* asset's blob URL being rewritten to where the file actually landed, most of all.
|
||||
*
|
||||
* The history is deliberately left alone rather than reset: an author who undoes past this point
|
||||
* gets the document they had, which is the lesser surprise.
|
||||
*/
|
||||
setMarkdown(markdown) {
|
||||
const doc = parser.parse(markdown ?? '', context.pagePath())
|
||||
const tr = view.state.tr.replaceWith(0, view.state.doc.content.size, doc.content)
|
||||
tr.setMeta('addToHistory', false)
|
||||
view.dispatch(tr)
|
||||
},
|
||||
|
||||
/**
|
||||
* Hand the document over to a collaborative session.
|
||||
*
|
||||
* Called once the room has synced and not before: the shared document is what the editor becomes,
|
||||
* and one that has not synced yet says nothing at all — binding to it early would replace the page
|
||||
* with an empty one and then save that.
|
||||
*
|
||||
* The room is seeded from what this browser is holding when nobody has filled it yet, which is
|
||||
* safe to race on; `collab.js` explains why.
|
||||
*/
|
||||
enableCollab({ ydoc, awareness }) {
|
||||
seedIfEmpty(ydoc, view.state.doc)
|
||||
collaborating = true
|
||||
/*
|
||||
No `doc`: `ySyncPlugin` produces it from the shared fragment, which is what keeps the binding
|
||||
and the document in step. See the note in `collab.js`.
|
||||
*/
|
||||
view.updateState(
|
||||
EditorState.create({
|
||||
schema,
|
||||
plugins: [...buildPlugins(true), ...collabPlugins({ ydoc, awareness })]
|
||||
})
|
||||
)
|
||||
/*
|
||||
The store follows immediately. What the room holds may differ from what this browser loaded --
|
||||
somebody else's unsaved edits -- and until this runs a save would send the copy that was
|
||||
replaced.
|
||||
*/
|
||||
onChange?.()
|
||||
},
|
||||
|
||||
destroy() {
|
||||
destroyed = true
|
||||
view.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Plugin } from 'prosemirror-state'
|
||||
|
||||
import { barButton } from './bar'
|
||||
import { schema } from './schema'
|
||||
|
||||
/**
|
||||
* Editing a link that is already in the page.
|
||||
*
|
||||
* A link is a MARK, not a node, so it gets none of what the block nodes get: there is no node view to
|
||||
* hang a header bar off, and nothing about a run of marked text announces that it can be changed. An
|
||||
* author could retype the words, because those are ordinary content, but the address behind them was
|
||||
* unreachable — the only way to change it was to delete the link and write another.
|
||||
*
|
||||
* So this is the missing affordance: a small bar that appears under the link the caret is in, and the
|
||||
* three things anybody wants to do to a link from there. It is a plugin rather than part of the
|
||||
* component because finding the link is a question about the document, and answering it needs the
|
||||
* selection on every transaction.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The link the selection sits in, as the range it covers.
|
||||
*
|
||||
* Marks split a textblock into runs, so a link whose middle word is bold is several children sharing
|
||||
* one link mark rather than a single one — which is why this walks outwards over adjacent children
|
||||
* carrying an EQUAL mark instead of taking the one the cursor happens to be in. `eq` and not identity:
|
||||
* two runs of the same link are two mark instances with the same attributes.
|
||||
*
|
||||
* @returns {?{ from, to, text, href, title, mdAttrs, marks }}
|
||||
* Null when the selection is not in a link.
|
||||
*/
|
||||
export function findLink(state) {
|
||||
const type = schema.marks.link
|
||||
const { $from } = state.selection
|
||||
|
||||
/*
|
||||
Which mark the cursor means. `$from.marks()` is what typing here would carry, which for a caret at
|
||||
the very start of a link is NOT the link — so the character on either side is consulted too, and a
|
||||
caret touching a link counts as being in it.
|
||||
*/
|
||||
const mark =
|
||||
type.isInSet($from.marks()) ||
|
||||
(($from.nodeBefore && type.isInSet($from.nodeBefore.marks)) ?? null) ||
|
||||
(($from.nodeAfter && type.isInSet($from.nodeAfter.marks)) ?? null)
|
||||
if (!mark) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parent = $from.parent
|
||||
const parentStart = $from.start()
|
||||
const runs = []
|
||||
parent.forEach((child, offset) => {
|
||||
runs.push({ child, from: parentStart + offset, to: parentStart + offset + child.nodeSize })
|
||||
})
|
||||
|
||||
const index = runs.findIndex(
|
||||
(run) => run.from <= $from.pos && $from.pos <= run.to && mark.isInSet(run.child.marks)
|
||||
)
|
||||
if (index < 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
let first = index
|
||||
let last = index
|
||||
while (first > 0 && mark.isInSet(runs[first - 1].child.marks)) {
|
||||
first--
|
||||
}
|
||||
while (last < runs.length - 1 && mark.isInSet(runs[last + 1].child.marks)) {
|
||||
last++
|
||||
}
|
||||
|
||||
const from = runs[first].from
|
||||
const to = runs[last].to
|
||||
return {
|
||||
from,
|
||||
to,
|
||||
text: state.doc.textBetween(from, to, '', ''),
|
||||
href: mark.attrs.href,
|
||||
title: mark.attrs.title ?? '',
|
||||
// -> Whatever `{…}` the link carries, so editing it does not drop a `target="_blank"`
|
||||
mdAttrs: mark.attrs.mdAttrs,
|
||||
// -> Everything the run carries besides the link, so replacing the text does not drop its emphasis
|
||||
marks: runs[first].child.marks.filter((m) => m.type !== type)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a link's address, text and title back over the range it occupies.
|
||||
*
|
||||
* The text is only replaced when it actually changed. Rewriting it unconditionally would flatten a
|
||||
* link whose words are partly bold into one run of whatever the first run was marked with, on every
|
||||
* save of a dialog where nobody touched the text.
|
||||
*/
|
||||
export function applyLink(view, range, { text, href, title, newTab }) {
|
||||
const type = schema.marks.link
|
||||
/*
|
||||
The link's other attributes are kept as they were and only `target` is decided here, so an id or a
|
||||
class somebody put on the link in the source survives being edited through the dialog.
|
||||
*/
|
||||
const mdAttrs = { ...range.mdAttrs }
|
||||
if (newTab) {
|
||||
mdAttrs.target = '_blank'
|
||||
} else {
|
||||
delete mdAttrs.target
|
||||
}
|
||||
const mark = type.create({
|
||||
href,
|
||||
title: title || null,
|
||||
mdAttrs: Object.keys(mdAttrs).length > 0 ? mdAttrs : null
|
||||
})
|
||||
const tr = view.state.tr
|
||||
|
||||
if (text && text !== range.text) {
|
||||
tr.replaceWith(range.from, range.to, schema.text(text, [...range.marks, mark]))
|
||||
} else {
|
||||
tr.removeMark(range.from, range.to, type)
|
||||
tr.addMark(range.from, range.to, mark)
|
||||
}
|
||||
view.dispatch(tr.scrollIntoView())
|
||||
view.focus()
|
||||
}
|
||||
|
||||
/** Take the link off, keeping the words it was on. */
|
||||
export function removeLink(view, range) {
|
||||
view.dispatch(view.state.tr.removeMark(range.from, range.to, schema.marks.link))
|
||||
view.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* The bar itself.
|
||||
*
|
||||
* @param {object} handlers
|
||||
* @param {(range: object) => void} handlers.onEdit Open whatever edits a link; given the range.
|
||||
* @param {(range: object) => void} handlers.onRemove Unlink; given the range.
|
||||
* @param {(key: string) => string} handlers.t
|
||||
*/
|
||||
export function linkBar({ onEdit, onRemove, t }) {
|
||||
return new Plugin({
|
||||
view(editorView) {
|
||||
const dom = document.createElement('div')
|
||||
dom.className = 'visual-link-bar'
|
||||
dom.hidden = true
|
||||
|
||||
const href = document.createElement('a')
|
||||
href.className = 'visual-link-bar-href'
|
||||
href.target = '_blank'
|
||||
href.rel = 'noopener'
|
||||
dom.append(href)
|
||||
|
||||
let range = null
|
||||
dom.append(
|
||||
barButton(t('editor.visual.link.edit'), () => range && onEdit(range)),
|
||||
barButton(t('editor.visual.link.remove'), () => range && onRemove(range))
|
||||
)
|
||||
|
||||
/*
|
||||
Into the element ProseMirror was mounted into rather than the body: that box scrolls with the
|
||||
document, so a bar placed against it stays on its link as the page is scrolled without anything
|
||||
having to listen for it.
|
||||
*/
|
||||
const surface = editorView.dom.parentNode
|
||||
surface.append(dom)
|
||||
|
||||
const update = (view) => {
|
||||
range = view.editable ? findLink(view.state) : null
|
||||
if (!range) {
|
||||
dom.hidden = true
|
||||
return
|
||||
}
|
||||
href.textContent = range.href
|
||||
href.href = range.href
|
||||
href.title = range.href
|
||||
|
||||
const coords = view.coordsAtPos(range.from)
|
||||
const box = surface.getBoundingClientRect()
|
||||
dom.hidden = false
|
||||
dom.style.top = `${coords.bottom - box.top + 6}px`
|
||||
dom.style.left = `${Math.max(0, coords.left - box.left)}px`
|
||||
}
|
||||
|
||||
update(editorView)
|
||||
return {
|
||||
update,
|
||||
destroy: () => dom.remove()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,652 @@
|
||||
import { MarkdownParser } from 'prosemirror-markdown'
|
||||
|
||||
import { KINDS, MARKER } from '@/renderers/modules/github-alerts'
|
||||
|
||||
import { readMdAttrs, schema } from './schema'
|
||||
|
||||
/**
|
||||
* Markdown into a ProseMirror document, through the site's own markdown-it.
|
||||
*
|
||||
* The one rule this file exists to keep: **the Visual editor parses what the renderer parses**. Not a
|
||||
* second markdown implementation reading the same text and mostly agreeing — the very instance
|
||||
* `renderers/markdown.js` built, with its plugins, its config and every collision fix in it. A
|
||||
* separate parser would drift, and the first symptom of the drift would be a page silently rewritten
|
||||
* on save.
|
||||
*
|
||||
* So all of the work here is a translation of the token stream markdown-it already produces. What it
|
||||
* cannot translate it keeps verbatim (`raw_block`, `raw_inline`), which is what makes opening an
|
||||
* arbitrary page in this editor safe rather than hopeful.
|
||||
*
|
||||
* Two things markdown-it does not hand over on its own, and which are recovered from the source text
|
||||
* instead:
|
||||
*
|
||||
* - **Abbreviation definitions.** `markdown-it-abbr` consumes the `*[X]: …` line entirely and emits
|
||||
* no token for it, so a round trip through the token stream alone would delete every one of them.
|
||||
* - **A block component's body.** MDC tokenises what is between the fences, but a block holding a
|
||||
* fenced source — a diagram, a formula — wants that text exactly as typed, which only the source
|
||||
* has.
|
||||
*/
|
||||
|
||||
/** `*[HTML]: Hyper Text Markup Language`, at the start of a line. */
|
||||
const ABBREVIATION = /^ {0,3}\*\[([^\]\n]+)\]:[ \t]*(.*)$/
|
||||
|
||||
/** The opening or closing line of a fenced code block, indented up to the three spaces markdown allows. */
|
||||
const FENCE = /^ {0,3}(`{3,}|~{3,})/
|
||||
|
||||
/**
|
||||
* The abbreviation definitions in a page, with the lines they sit on.
|
||||
*
|
||||
* A definition inside a fenced code block is a code sample and not a definition, which is the same
|
||||
* reading `helpers/markdownBlocks.js` takes of the same lines — so fences are tracked rather than the
|
||||
* pattern simply being run over the whole document.
|
||||
*/
|
||||
function findAbbreviations(text) {
|
||||
const found = []
|
||||
let fence = null
|
||||
for (const line of text.split('\n')) {
|
||||
const fenceMatch = FENCE.exec(line)
|
||||
if (fenceMatch) {
|
||||
if (fence && line.trimStart().startsWith(fence)) {
|
||||
fence = null
|
||||
} else if (!fence) {
|
||||
fence = fenceMatch[1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (fence) {
|
||||
continue
|
||||
}
|
||||
const match = ABBREVIATION.exec(line)
|
||||
if (match) {
|
||||
found.push({ label: match[1], title: match[2].trim() })
|
||||
}
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
/**
|
||||
* A synthetic markdown-it token, for the constructs assembled here rather than parsed.
|
||||
*
|
||||
* Plain objects rather than `Token` instances: the parser only ever reads `type`, `attrs`, `content`,
|
||||
* `children` and `meta` off them, and `attrGet` is the one method it calls.
|
||||
*/
|
||||
function token(type, fields = {}) {
|
||||
return {
|
||||
type,
|
||||
tag: '',
|
||||
attrs: null,
|
||||
content: '',
|
||||
children: null,
|
||||
meta: null,
|
||||
nesting: 0,
|
||||
attrGet(name) {
|
||||
return this.attrs?.find(([key]) => key === name)?.[1] ?? null
|
||||
},
|
||||
...fields
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A copy of a markdown-it token with some fields changed.
|
||||
*
|
||||
* Not a spread: `Token` keeps `attrGet` on its prototype, and a spread produces a plain object
|
||||
* without it — which reads as "this token has no attributes" everywhere downstream rather than as an
|
||||
* error. That silently emptied every `[text]{.cls}` span of its class.
|
||||
*/
|
||||
function retype(tok, fields) {
|
||||
return Object.assign(Object.create(Object.getPrototypeOf(tok)), tok, fields)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a block component's body is document content or a source it merely carries.
|
||||
*
|
||||
* Decided from what the body actually parsed into rather than from a list of block names, so a block
|
||||
* this file has never heard of — a custom one an installation added — is classified by the same rule
|
||||
* as the ones that ship. A body that is empty, or that is nothing but one fenced source, belongs to
|
||||
* the block; anything else is page content and is edited in place.
|
||||
*
|
||||
* This is the same division the blocks themselves make: `block-tab` and `block-steps` leave what they
|
||||
* hold in the light DOM precisely because it is the article's content and has to be drawn by the
|
||||
* article's stylesheet.
|
||||
*
|
||||
* @param {object[]} body The tokens between the opening and closing fences.
|
||||
*/
|
||||
function bodyIsContent(body) {
|
||||
if (body.length === 0) {
|
||||
return false
|
||||
}
|
||||
return !(body.length === 1 && body[0].type === 'fence')
|
||||
}
|
||||
|
||||
/** An MDC block's body, taken out of the source between its opening and closing fence lines. */
|
||||
function blockBody(lines, map) {
|
||||
if (!map) {
|
||||
return ''
|
||||
}
|
||||
// -> `map[1]` is the line the closing fence sits on, so the body is everything strictly between
|
||||
return lines
|
||||
.slice(map[0] + 1, map[1])
|
||||
.join('\n')
|
||||
.replace(/\s+$/, '')
|
||||
}
|
||||
|
||||
/** A token's attribute list as a plain object, for the block attributes MDC puts on the open token. */
|
||||
function attrObject(attrs) {
|
||||
if (!attrs || attrs.length === 0) {
|
||||
return null
|
||||
}
|
||||
return Object.fromEntries(attrs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline tokens, with the three places MDC and the task list plugin leave the stream unusable put
|
||||
* right.
|
||||
*
|
||||
* All three are cases where a token means something only in combination with its neighbours, which is
|
||||
* exactly what `MarkdownParser` cannot express: it dispatches on one token's type at a time.
|
||||
*/
|
||||
function normalizeInline(children, config) {
|
||||
const out = []
|
||||
for (let i = 0; i < children.length; i++) {
|
||||
const tok = children[i]
|
||||
|
||||
/*
|
||||
MDC's inline span puts its attributes on a THIRD token, after the close — `[text]{.cls}` is
|
||||
span-open, text, span-close, props. A mark's attributes have to be on its opening token, so the
|
||||
props are carried back to it and the token itself dropped.
|
||||
|
||||
The same props token also follows an image (`{.cls}`), where it decorates that image
|
||||
rather than opening anything, and is folded onto it for the same reason.
|
||||
*/
|
||||
if (tok.type === 'mdc_inline_props') {
|
||||
const previous = out.at(-1)
|
||||
if (previous?.type === 'mdc_span_close') {
|
||||
const open = out.findLast((entry) => entry.type === 'mdc_span_open' && !entry.meta?.closed)
|
||||
if (open) {
|
||||
open.attrs = tok.attrs
|
||||
open.meta = { ...open.meta, closed: true }
|
||||
}
|
||||
} else if (previous?.type === 'image') {
|
||||
previous.meta = { ...previous.meta, props: tok.attrs }
|
||||
} else if (previous?.type === 'link_close') {
|
||||
// -> Back onto the opening token, which is where a mark's attributes have to be
|
||||
const open = out.findLast((entry) => entry.type === 'link_open' && !entry.meta?.closed)
|
||||
if (open) {
|
||||
open.meta = { ...open.meta, props: tok.attrs, closed: true }
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (tok.type === 'mdc_inline_span') {
|
||||
out.push(retype(tok, { type: tok.nesting === 1 ? 'mdc_span_open' : 'mdc_span_close' }))
|
||||
continue
|
||||
}
|
||||
|
||||
/*
|
||||
`_text_` is an underline rather than an emphasis on a site that turns `underline` on — see the
|
||||
mark in the schema for why that distinction cannot be normalised away. The markup character is
|
||||
the only thing that separates the two, and markdown-it keeps it on the token.
|
||||
*/
|
||||
if (config.underline && (tok.type === 'em_open' || tok.type === 'em_close')) {
|
||||
if (tok.markup === '_') {
|
||||
out.push(
|
||||
retype(tok, { type: tok.type === 'em_open' ? 'md_underline_open' : 'md_underline_close' })
|
||||
)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
The checkbox `markdown-it-task-lists` writes. It is the marker of a task item rather than part of
|
||||
its text, and the item itself carries whether it is ticked, so there is nothing here for it to
|
||||
contribute.
|
||||
|
||||
The plugin slices the `[x] ` off the text itself, marker and space together, so nothing is left
|
||||
to trim after it — see the guard in `renderers/markdown.js` that keeps MDC's inline span off
|
||||
that marker, without which it silently sliced nothing and left the marker in the page.
|
||||
*/
|
||||
if (tok.type === 'html_inline' && tok.content.includes('task-list-item-checkbox')) {
|
||||
continue
|
||||
}
|
||||
|
||||
out.push(tok)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The whole token stream, rewritten into something `MarkdownParser` can dispatch on.
|
||||
*
|
||||
* Everything here is structural: a token type that has to be split by what it is a token OF (MDC
|
||||
* blocks all arrive as `mdc_block_open`, whatever component they name), a token that has to be
|
||||
* dropped because the schema models it as an attribute, or content that has to be recovered from the
|
||||
* source because the tokens no longer hold it.
|
||||
*/
|
||||
function normalize(tokens, source, config) {
|
||||
const lines = source.split('\n')
|
||||
const out = []
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const tok = tokens[i]
|
||||
|
||||
switch (tok.type) {
|
||||
/*
|
||||
Every block component arrives under one token type and is told apart by its tag, so the tag is
|
||||
what the dispatch has to be moved onto. A block whose body is a source it carries becomes a
|
||||
single atom holding that source verbatim; one whose body is page content becomes a container
|
||||
and the tokens inside it are kept.
|
||||
*/
|
||||
case 'mdc_block_open': {
|
||||
const depth = []
|
||||
let end = i + 1
|
||||
for (; end < tokens.length; end++) {
|
||||
if (tokens[end].type === 'mdc_block_open') {
|
||||
depth.push(1)
|
||||
} else if (tokens[end].type === 'mdc_block_close') {
|
||||
if (depth.length === 0) {
|
||||
break
|
||||
}
|
||||
depth.pop()
|
||||
}
|
||||
}
|
||||
const name = tok.tag.replace(/^block-/, '')
|
||||
const blockAttrs = attrObject(tok.attrs)
|
||||
if (bodyIsContent(tokens.slice(i + 1, end))) {
|
||||
out.push(token('mdc_container_open', { meta: { name, blockAttrs } }))
|
||||
out.push(...normalize(tokens.slice(i + 1, end), source, config))
|
||||
out.push(token('mdc_container_close'))
|
||||
} else {
|
||||
out.push(
|
||||
token('mdc_atom', { meta: { name, blockAttrs, body: blockBody(lines, tok.map) } })
|
||||
)
|
||||
}
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
|
||||
/*
|
||||
A blockquote opening with `[!NOTE]` is an alert, and a different piece of source from a
|
||||
blockquote somebody classed by hand. The plugin has already rewritten the tokens by the time
|
||||
this runs — the marker is gone from the text and its title is a paragraph of its own — so the
|
||||
alert is recognised from the same marker, read off the ORIGINAL source line.
|
||||
*/
|
||||
case 'blockquote_open': {
|
||||
const alert = alertAt(tokens, i, lines)
|
||||
if (alert) {
|
||||
out.push(token('md_alert_open', { meta: alert }))
|
||||
// -> The title has been spliced in as a paragraph; it belongs to the marker line, not the body
|
||||
i = alert.skipTo - 1
|
||||
continue
|
||||
}
|
||||
out.push(tok)
|
||||
continue
|
||||
}
|
||||
case 'blockquote_close':
|
||||
out.push(tokens[i].meta?.alert ? token('md_alert_close') : tok)
|
||||
continue
|
||||
|
||||
/*
|
||||
`prosemirror-tables` has no row groups: a table holds rows directly. The header row is told
|
||||
apart by its cells being header cells, which is the same thing markdown says with its
|
||||
delimiter row.
|
||||
*/
|
||||
case 'thead_open':
|
||||
case 'thead_close':
|
||||
case 'tbody_open':
|
||||
case 'tbody_close':
|
||||
continue
|
||||
|
||||
/*
|
||||
A cell's content in this schema is block content, because that is what a cell can hold once a
|
||||
table is edited rather than written — but markdown-it puts the inline straight inside the cell.
|
||||
The paragraph it will need is added here.
|
||||
*/
|
||||
case 'th_open':
|
||||
case 'td_open':
|
||||
out.push(tok)
|
||||
if (tokens[i + 1]?.type === 'inline') {
|
||||
out.push(token('paragraph_open', { type: 'paragraph_open', nesting: 1 }))
|
||||
out.push(normalizeInlineToken(tokens[i + 1], config))
|
||||
out.push(token('paragraph_close', { type: 'paragraph_close', nesting: -1 }))
|
||||
i += 1
|
||||
}
|
||||
continue
|
||||
|
||||
/*
|
||||
Whether a task item is ticked has to be read here, while the checkbox the task list plugin
|
||||
wrote is still in the stream — `normalizeInline` is about to strip it as the marker it is, so
|
||||
by the time the item's attributes are computed there would be nothing left to read.
|
||||
*/
|
||||
case 'list_item_open':
|
||||
out.push(retype(tok, { meta: { ...tok.meta, checked: checkedInItem(tokens, i) } }))
|
||||
continue
|
||||
|
||||
case 'inline':
|
||||
out.push(normalizeInlineToken(tok, config))
|
||||
continue
|
||||
|
||||
/*
|
||||
The footnote section markdown-it appends at the end of the document. The wrapper and the
|
||||
back-reference anchor are rendering furniture with no source behind them; the definitions
|
||||
themselves are kept.
|
||||
*/
|
||||
case 'footnote_block_open':
|
||||
case 'footnote_block_close':
|
||||
case 'footnote_anchor':
|
||||
continue
|
||||
|
||||
default:
|
||||
out.push(tok)
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/** An inline token with its children normalised, leaving the original untouched. */
|
||||
function normalizeInlineToken(tok, config) {
|
||||
return retype(tok, { children: normalizeInline(tok.children ?? [], config) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the blockquote opening at `index` is a GitHub alert, and what it says.
|
||||
*
|
||||
* Read from the source line rather than from the tokens, because by this point the plugin has taken
|
||||
* the marker out of the text — and a blockquote an author classed `{.is-warning}` by hand carries the
|
||||
* very same class, so the class is not evidence of anything.
|
||||
*/
|
||||
function alertAt(tokens, index, lines) {
|
||||
const open = tokens[index]
|
||||
const first = open.map ? lines[open.map[0]] : null
|
||||
if (!first) {
|
||||
return null
|
||||
}
|
||||
const body = first.replace(/^ {0,3}>[ \t]?/, '')
|
||||
if (body === first) {
|
||||
return null
|
||||
}
|
||||
const match = MARKER.exec(body)
|
||||
const kind = match ? match[1].toLowerCase() : null
|
||||
if (!kind || !KINDS.has(kind)) {
|
||||
return null
|
||||
}
|
||||
|
||||
/*
|
||||
The plugin replaced the marker with a title paragraph, or spliced one in ahead of the body. Either
|
||||
way the three tokens making up that paragraph are furniture and are stepped over; what they say is
|
||||
carried on the alert itself.
|
||||
*/
|
||||
let skipTo = index + 1
|
||||
if (
|
||||
tokens[skipTo]?.type === 'paragraph_open' &&
|
||||
tokens[skipTo].attrGet?.('class') === 'alert-title'
|
||||
) {
|
||||
skipTo += 3
|
||||
}
|
||||
// -> An empty title means the marker stood alone, and the kind's own label is what gets drawn
|
||||
const title = match[2].trim()
|
||||
return { kind, title: title || null, skipTo }
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the blockquote closes that belong to an alert, so the close can be rewritten too.
|
||||
*
|
||||
* Done as a pass of its own because the open and the close are matched by nesting, which is cheaper
|
||||
* to compute over the whole array once than to track through the rewrite.
|
||||
*/
|
||||
function markAlertCloses(tokens, lines) {
|
||||
const stack = []
|
||||
for (const [index, tok] of tokens.entries()) {
|
||||
if (tok.type === 'blockquote_open') {
|
||||
stack.push(alertAt(tokens, index, lines) !== null)
|
||||
} else if (tok.type === 'blockquote_close') {
|
||||
if (stack.pop()) {
|
||||
tok.meta = { ...tok.meta, alert: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How each token becomes part of the document.
|
||||
*
|
||||
* Everything the schema models is here; anything not listed reaches `MarkdownParser` as an unknown
|
||||
* token and would throw, which is why the normaliser above turns the whole stream into these types
|
||||
* and nothing else.
|
||||
*/
|
||||
const tokenSpecs = {
|
||||
paragraph: { block: 'paragraph', getAttrs: (tok) => ({ mdAttrs: readMdAttrs(tok) }) },
|
||||
heading: {
|
||||
block: 'heading',
|
||||
getAttrs: (tok) => ({ level: Number(tok.tag.slice(1)), mdAttrs: readMdAttrs(tok) })
|
||||
},
|
||||
blockquote: { block: 'blockquote', getAttrs: (tok) => ({ mdAttrs: readMdAttrs(tok) }) },
|
||||
md_alert: {
|
||||
block: 'alert',
|
||||
getAttrs: (tok) => ({ kind: tok.meta.kind, title: tok.meta.title })
|
||||
},
|
||||
code_block: { block: 'code_block', getAttrs: () => ({ indented: true }), noCloseToken: true },
|
||||
fence: {
|
||||
block: 'code_block',
|
||||
getAttrs: (tok) => ({ params: tok.info ?? '' }),
|
||||
noCloseToken: true
|
||||
},
|
||||
hr: { node: 'horizontal_rule', getAttrs: (tok) => ({ markup: tok.markup || '---' }) },
|
||||
|
||||
bullet_list: {
|
||||
block: 'bullet_list',
|
||||
getAttrs: (tok, tokens, i) => ({
|
||||
tight: listIsTight(tokens, i),
|
||||
markup: tok.markup || '-',
|
||||
// -> The task list plugin's own class is not the author's, and is not written back
|
||||
mdAttrs: readMdAttrs(tok, ['contains-task-list'])
|
||||
})
|
||||
},
|
||||
ordered_list: {
|
||||
block: 'ordered_list',
|
||||
getAttrs: (tok, tokens, i) => ({
|
||||
order: Number(tok.attrGet('start')) || 1,
|
||||
tight: listIsTight(tokens, i),
|
||||
markup: tok.markup || '.',
|
||||
mdAttrs: readMdAttrs(tok, ['contains-task-list'])
|
||||
})
|
||||
},
|
||||
list_item: { block: 'list_item', getAttrs: (tok) => ({ checked: checkedState(tok) }) },
|
||||
|
||||
dl: {
|
||||
block: 'definition_list',
|
||||
getAttrs: (tok, tokens, i) => ({ tight: deflistIsTight(tokens, i) })
|
||||
},
|
||||
dt: { block: 'definition_term' },
|
||||
dd: { block: 'definition_description' },
|
||||
|
||||
table: { block: 'table' },
|
||||
tr: { block: 'table_row' },
|
||||
th: { block: 'table_header', getAttrs: (tok) => ({ align: alignOf(tok) }) },
|
||||
td: { block: 'table_cell', getAttrs: (tok) => ({ align: alignOf(tok) }) },
|
||||
|
||||
mdc_container: {
|
||||
block: 'block_container',
|
||||
getAttrs: (tok) => ({ name: tok.meta.name, blockAttrs: tok.meta.blockAttrs })
|
||||
},
|
||||
mdc_atom: {
|
||||
node: 'block_component',
|
||||
getAttrs: (tok) => ({
|
||||
name: tok.meta.name,
|
||||
blockAttrs: tok.meta.blockAttrs,
|
||||
body: tok.meta.body
|
||||
})
|
||||
},
|
||||
|
||||
footnote: { block: 'footnote_definition', getAttrs: (tok) => ({ label: tok.meta.label }) },
|
||||
footnote_ref: { node: 'footnote_ref', getAttrs: (tok) => ({ label: tok.meta.label }) },
|
||||
|
||||
html_block: {
|
||||
node: 'raw_block',
|
||||
getAttrs: (tok) => ({ source: tok.content.replace(/\n$/, '') })
|
||||
},
|
||||
html_inline: { node: 'raw_inline', getAttrs: (tok) => ({ source: tok.content }) },
|
||||
|
||||
image: {
|
||||
node: 'image',
|
||||
getAttrs: (tok) => ({
|
||||
src: tok.attrGet('src'),
|
||||
alt: tok.children?.[0]?.content || null,
|
||||
title: tok.attrGet('title') || null,
|
||||
width: tok.attrGet('width'),
|
||||
height: tok.attrGet('height'),
|
||||
mdAttrs: readMdAttrs({ attrs: tok.meta?.props })
|
||||
})
|
||||
},
|
||||
hardbreak: { node: 'hard_break', getAttrs: () => ({ markup: 'hard' }) },
|
||||
softbreak: { node: 'hard_break', getAttrs: () => ({ markup: 'soft' }) },
|
||||
emoji: {
|
||||
node: 'emoji',
|
||||
getAttrs: (tok) => ({ shortcode: tok.markup, char: tok.content })
|
||||
},
|
||||
iconify_icon: { node: 'icon', getAttrs: (tok) => ({ reference: tok.content }) },
|
||||
|
||||
em: { mark: 'em' },
|
||||
md_underline: { mark: 'underline' },
|
||||
strong: { mark: 'strong' },
|
||||
s: { mark: 'strike' },
|
||||
mark: { mark: 'mark' },
|
||||
sub: { mark: 'sub' },
|
||||
sup: { mark: 'sup' },
|
||||
abbr: { mark: 'abbr', getAttrs: (tok) => ({ title: tok.attrGet('title') ?? '' }) },
|
||||
mdc_span: { mark: 'span', getAttrs: (tok) => ({ mdAttrs: readMdAttrs(tok) }) },
|
||||
link: {
|
||||
mark: 'link',
|
||||
getAttrs: (tok) => ({
|
||||
href: tok.attrGet('href'),
|
||||
title: tok.attrGet('title') || null,
|
||||
mdAttrs: readMdAttrs({ attrs: tok.meta?.props })
|
||||
})
|
||||
},
|
||||
code_inline: { mark: 'code', noCloseToken: true }
|
||||
}
|
||||
|
||||
/** Whether a list is tight, which is whether its items' paragraphs are rendered. */
|
||||
function listIsTight(tokens, index) {
|
||||
for (let i = index + 1; i < tokens.length; i++) {
|
||||
if (tokens[i].type !== 'list_item_open') {
|
||||
return Boolean(tokens[i].hidden)
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a list item is a task, and whether it is ticked.
|
||||
*
|
||||
* Null for an ordinary item — which is not the same as an unticked one, and is what decides whether
|
||||
* the item is written back with a `[ ]` marker at all.
|
||||
*/
|
||||
function checkedState(tok) {
|
||||
return tok.meta?.checked ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The same question asked of the raw stream, which is where the answer actually is.
|
||||
*
|
||||
* The class marks the item as a task; the `checked` attribute of the input the plugin wrote is what
|
||||
* says whether it is ticked.
|
||||
*/
|
||||
function checkedInItem(tokens, index) {
|
||||
const classes = tokens[index].attrGet('class') ?? ''
|
||||
if (!classes.split(/\s+/).includes('task-list-item')) {
|
||||
return null
|
||||
}
|
||||
for (let i = index + 1; i < tokens.length && i < index + 6; i++) {
|
||||
for (const child of tokens[i].children ?? []) {
|
||||
if (child.type === 'html_inline' && child.content.includes('task-list-item-checkbox')) {
|
||||
return child.content.includes('checked')
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a definition list is tight, which is whether its definitions' paragraphs are rendered.
|
||||
*
|
||||
* The same tell as for a bullet list — a hidden paragraph token — read off the first definition,
|
||||
* since `markdown-it-deflist` decides tightness for the list as a whole.
|
||||
*/
|
||||
function deflistIsTight(tokens, index) {
|
||||
for (let i = index + 1; i < tokens.length; i++) {
|
||||
if (tokens[i].type === 'dl_close') {
|
||||
return true
|
||||
}
|
||||
if (tokens[i].type === 'paragraph_open') {
|
||||
return Boolean(tokens[i].hidden)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** A cell's alignment, which markdown-it writes as an inline style. */
|
||||
function alignOf(tok) {
|
||||
const match = /text-align:\s*(left|center|right)/.exec(tok.attrGet('style') ?? '')
|
||||
return match ? match[1] : null
|
||||
}
|
||||
|
||||
/**
|
||||
* A parser bound to one site's markdown configuration.
|
||||
*
|
||||
* @param {import('@/renderers/markdown').MarkdownRenderer} renderer The renderer the editor previews
|
||||
* with. Its markdown-it instance is the tokenizer, which is the whole point.
|
||||
* @param {object} config The site's markdown editor config, for the handful of settings that change
|
||||
* what a token MEANS rather than how it is drawn — `underline` being the one that matters.
|
||||
*/
|
||||
export function createParser(renderer, config = {}) {
|
||||
const md = renderer.md
|
||||
|
||||
/*
|
||||
The tokenizer `MarkdownParser` is given is this shim rather than markdown-it itself, which is the
|
||||
seam that makes all of the above possible: the parser calls `parse(text, env)` and never looks at
|
||||
what produced the tokens.
|
||||
*/
|
||||
const tokenizer = {
|
||||
parse(text, env) {
|
||||
const tokens = md.parse(text, env)
|
||||
const lines = text.split('\n')
|
||||
markAlertCloses(tokens, lines)
|
||||
const normalized = normalize(tokens, text, config)
|
||||
/*
|
||||
The abbreviation definitions, appended as nodes of their own. They are recovered from the
|
||||
source because the plugin leaves no token behind — see the note at the top of this file — and
|
||||
they go at the end because that is where the editor's own insert puts them and where they can
|
||||
be gathered without a second structure tracking where each one was written.
|
||||
*/
|
||||
for (const abbreviation of findAbbreviations(text)) {
|
||||
normalized.push(
|
||||
token('md_abbreviation', {
|
||||
meta: abbreviation
|
||||
})
|
||||
)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
const parser = new MarkdownParser(schema, tokenizer, {
|
||||
...tokenSpecs,
|
||||
md_abbreviation: {
|
||||
node: 'abbreviation',
|
||||
getAttrs: (tok) => ({ label: tok.meta.label, title: tok.meta.title })
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
/**
|
||||
* @param {string} markdown The page source.
|
||||
* @param {string} pagePath The page's own path, which a relative image resolves against — passed
|
||||
* through to markdown-it as its environment, exactly as the preview does it.
|
||||
*/
|
||||
parse(markdown, pagePath = '') {
|
||||
return parser.parse(markdown ?? '', { pagePath })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,669 @@
|
||||
import { Schema } from 'prosemirror-model'
|
||||
import { tableNodes } from 'prosemirror-tables'
|
||||
|
||||
import { KINDS } from '@/renderers/modules/github-alerts'
|
||||
|
||||
/**
|
||||
* The document model the Visual editor edits.
|
||||
*
|
||||
* Every node here exists because some markdown the site's pipeline understands has to survive being
|
||||
* opened and saved again. That is the whole design brief: the source is markdown, this is a view of
|
||||
* it, and a construct with no node of its own would be silently rewritten into something else the
|
||||
* next time an author pressed Save.
|
||||
*
|
||||
* So the schema is not "what a rich text editor offers" — it is `renderers/markdown.js` read
|
||||
* backwards. Where the two disagree the renderer wins, because the renderer is what produces the page
|
||||
* a reader sees.
|
||||
*
|
||||
* Two conventions run through all of it:
|
||||
*
|
||||
* - **`mdAttrs`** is the `{#id .class target=…}` suffix `markdown-it-attrs` reads off the end of a
|
||||
* block. It is kept as a plain object of the three attributes that plugin is configured to allow,
|
||||
* and written back out in a fixed order, which is why converting a page normalises the spacing
|
||||
* inside those braces.
|
||||
* - **`markup`** is the character the author actually used where markdown offers a choice — `-` or
|
||||
* `*` for a bullet, `_` or `*` for emphasis. Dropping it would be a diff on every line of a list
|
||||
* that happened to be written the other way.
|
||||
*
|
||||
* `toDOM` here is what the *editor* draws, not what the page shows: the page is rendered from the
|
||||
* markdown by the real pipeline. Where the two can cheaply agree they do, so that the content
|
||||
* stylesheet makes the editor look like the page.
|
||||
*/
|
||||
|
||||
/** The attributes `markdown-it-attrs` is allowed to read, in the order they are written back. */
|
||||
export const MD_ATTR_NAMES = ['id', 'class', 'target']
|
||||
|
||||
/**
|
||||
* Blocks that may carry a `{…}` suffix, as a spec fragment to spread into their `attrs`.
|
||||
*
|
||||
* Null rather than an empty object for "nothing was written", so that a block nobody classed
|
||||
* serialises with no braces at all rather than with an empty pair.
|
||||
*/
|
||||
const mdAttrs = { mdAttrs: { default: null } }
|
||||
|
||||
/**
|
||||
* Turn a markdown-it token's attribute list into what `mdAttrs` holds.
|
||||
*
|
||||
* Only the three names the plugin is configured for: anything else on the token was put there by the
|
||||
* renderer rather than by the author — the alert rule's `is-warning`, the task list plugin's
|
||||
* `contains-task-list` — and writing those back would be the editor inventing source the author never
|
||||
* typed.
|
||||
*
|
||||
* The order the token lists them in is kept, because it is the order they were written in and
|
||||
* `writeMdAttrs` puts them back the same way. Sorting them into a canonical order instead would
|
||||
* rewrite `{.cls #x}` as `{#x .cls}` on every page that happened to use the other one.
|
||||
*
|
||||
* @param {{ attrs?: Array<[string, string]> }} token A markdown-it token, or anything with its
|
||||
* `attrs` shape.
|
||||
* @param {string[]} [skipClasses] Classes the renderer added, which are not the author's to keep.
|
||||
* @returns {object|null} The attributes, or null when there are none worth writing.
|
||||
*/
|
||||
export function readMdAttrs(token, skipClasses = []) {
|
||||
const out = {}
|
||||
for (const [name, raw] of token?.attrs ?? []) {
|
||||
if (!MD_ATTR_NAMES.includes(name) || raw === null || raw === undefined || raw === '') {
|
||||
continue
|
||||
}
|
||||
let value = raw
|
||||
if (name === 'class' && skipClasses.length > 0) {
|
||||
value = value
|
||||
.split(/\s+/)
|
||||
.filter((entry) => entry && !skipClasses.includes(entry))
|
||||
.join(' ')
|
||||
if (!value) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[name] = value
|
||||
}
|
||||
return Object.keys(out).length > 0 ? out : null
|
||||
}
|
||||
|
||||
/**
|
||||
* The `{…}` suffix for a set of attributes, the empty string when there are none.
|
||||
*
|
||||
* `#id` and `.class` use the shorthands, because that is what an author writes and what the editor's
|
||||
* own insert paths produce; anything else is written out as a pair. A class list becomes one `.name`
|
||||
* per class, which is how `markdown-it-attrs` reads it back. Written in the object's own key order,
|
||||
* which `readMdAttrs` took from the source.
|
||||
*/
|
||||
export function writeMdAttrs(attrs) {
|
||||
if (!attrs) {
|
||||
return ''
|
||||
}
|
||||
const parts = []
|
||||
for (const [name, value] of Object.entries(attrs)) {
|
||||
if (!value) {
|
||||
continue
|
||||
}
|
||||
if (name === 'id') {
|
||||
parts.push(`#${value}`)
|
||||
} else if (name === 'class') {
|
||||
for (const entry of String(value).split(/\s+/).filter(Boolean)) {
|
||||
parts.push(`.${entry}`)
|
||||
}
|
||||
} else {
|
||||
// -> Quoted, which is what the Markdown editor and `helpers/blocks.js` both write: unquoted
|
||||
// parses back the same, but the two editors saving one page should not disagree about it
|
||||
parts.push(`${name}="${String(value).replaceAll('"', "'")}"`)
|
||||
}
|
||||
}
|
||||
return parts.length > 0 ? `{${parts.join(' ')}}` : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* The alert kinds, taken from the plugin that defines them so the two cannot drift.
|
||||
*
|
||||
* The editor draws an alert with the class the rendered page will carry, which is what makes the
|
||||
* content stylesheet dress both the same way — see `renderers/modules/github-alerts.js` for why those
|
||||
* classes are the admonition ones rather than a set of its own.
|
||||
*/
|
||||
export const ALERT_KINDS = Object.fromEntries(
|
||||
[...KINDS].map(([kind, spec]) => [kind, spec.className])
|
||||
)
|
||||
|
||||
const tables = tableNodes({
|
||||
tableGroup: 'block',
|
||||
cellContent: 'block+',
|
||||
cellAttributes: {
|
||||
/*
|
||||
Which way the column is set, as markdown says it: the colons in the delimiter row. It belongs to
|
||||
the column rather than to the cell, but markdown-it puts it on every cell as a `style` attribute
|
||||
and the serialiser reads it back off the first row, so this is where it can be kept without a
|
||||
second structure to hold a table's columns.
|
||||
*/
|
||||
align: {
|
||||
default: null,
|
||||
getFromDOM: (dom) => dom.style.textAlign || null,
|
||||
setDOMAttr: (value, attrs) => {
|
||||
if (value) {
|
||||
attrs.style = `${attrs.style ?? ''}text-align: ${value};`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
export const schema = new Schema({
|
||||
nodes: {
|
||||
doc: { content: 'block+' },
|
||||
|
||||
paragraph: {
|
||||
content: 'inline*',
|
||||
group: 'block',
|
||||
attrs: { ...mdAttrs },
|
||||
parseDOM: [{ tag: 'p' }],
|
||||
toDOM: (node) => ['p', domAttrs(node), 0]
|
||||
},
|
||||
|
||||
heading: {
|
||||
content: 'inline*',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
attrs: { level: { default: 1 }, ...mdAttrs },
|
||||
parseDOM: [1, 2, 3, 4, 5, 6].map((level) => ({ tag: `h${level}`, attrs: { level } })),
|
||||
toDOM: (node) => [`h${node.attrs.level}`, domAttrs(node), 0]
|
||||
},
|
||||
|
||||
blockquote: {
|
||||
content: 'block+',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
attrs: { ...mdAttrs },
|
||||
parseDOM: [{ tag: 'blockquote' }],
|
||||
toDOM: (node) => ['blockquote', domAttrs(node), 0]
|
||||
},
|
||||
|
||||
/**
|
||||
* A GitHub-style alert: `> [!NOTE] Optional title`.
|
||||
*
|
||||
* Its own node rather than a classed blockquote, because the two are different source. The title
|
||||
* is a string and not editable content — it is one line of markdown the renderer parses on its
|
||||
* own, and modelling it as a child would mean a node that can hold inline content but must
|
||||
* serialise onto the marker line.
|
||||
*/
|
||||
alert: {
|
||||
content: 'block+',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
attrs: { kind: { default: 'note' }, title: { default: null } },
|
||||
parseDOM: [{ tag: 'blockquote[data-alert]' }],
|
||||
toDOM: (node) => [
|
||||
'blockquote',
|
||||
{ class: `alert ${ALERT_KINDS[node.attrs.kind] ?? ''}`, 'data-alert': node.attrs.kind },
|
||||
0
|
||||
]
|
||||
},
|
||||
|
||||
code_block: {
|
||||
content: 'text*',
|
||||
group: 'block',
|
||||
code: true,
|
||||
defining: true,
|
||||
marks: '',
|
||||
/*
|
||||
The whole info string, not just the language: a fence here may also carry `title`,
|
||||
`linesStart` and `linesHighlight`, which the renderer parses out of exactly this text. Kept as
|
||||
one string so a fence the editor has no form for still comes back the way it went in.
|
||||
|
||||
`indented` is the other way markdown writes a code block — four spaces and no fence. It is not
|
||||
a spelling difference to be normalised away: the renderer sends a fence through highlight.js
|
||||
and gives it the wiki's code styling, while an indented block gets the browser's bare `<pre>`,
|
||||
so rewriting one as the other changes the page.
|
||||
*/
|
||||
attrs: { params: { default: '' }, indented: { default: false } },
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'pre',
|
||||
preserveWhitespace: 'full',
|
||||
getAttrs: (dom) => ({ params: dom.getAttribute('data-params') ?? '' })
|
||||
}
|
||||
],
|
||||
toDOM: (node) => ['pre', { 'data-params': node.attrs.params || null }, ['code', 0]]
|
||||
},
|
||||
|
||||
horizontal_rule: {
|
||||
group: 'block',
|
||||
attrs: { markup: { default: '---' } },
|
||||
parseDOM: [{ tag: 'hr' }],
|
||||
toDOM: () => ['div', { class: 'visual-hr' }, ['hr']]
|
||||
},
|
||||
|
||||
bullet_list: {
|
||||
content: 'list_item+',
|
||||
group: 'block',
|
||||
attrs: { tight: { default: true }, markup: { default: '-' }, ...mdAttrs },
|
||||
parseDOM: [{ tag: 'ul', getAttrs: (dom) => ({ tight: dom.hasAttribute('data-tight') }) }],
|
||||
toDOM: (node) => ['ul', domAttrs(node), 0]
|
||||
},
|
||||
|
||||
ordered_list: {
|
||||
content: 'list_item+',
|
||||
group: 'block',
|
||||
attrs: {
|
||||
order: { default: 1 },
|
||||
tight: { default: true },
|
||||
markup: { default: '.' },
|
||||
...mdAttrs
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'ol',
|
||||
getAttrs: (dom) => ({
|
||||
order: dom.hasAttribute('start') ? Number(dom.getAttribute('start')) : 1,
|
||||
tight: dom.hasAttribute('data-tight')
|
||||
})
|
||||
}
|
||||
],
|
||||
toDOM: (node) => [
|
||||
'ol',
|
||||
{ ...domAttrs(node), start: node.attrs.order === 1 ? null : node.attrs.order },
|
||||
0
|
||||
]
|
||||
},
|
||||
|
||||
/**
|
||||
* One item of either list, and — when `checked` is not null — one line of a task list.
|
||||
*
|
||||
* The checkbox is an attribute of the item rather than a node inside it because that is what it is
|
||||
* in the source: `- [x] ` is the item's marker, not its first child. Modelling it as content would
|
||||
* let an author put it in the middle of a sentence.
|
||||
*/
|
||||
list_item: {
|
||||
content: 'block+',
|
||||
defining: true,
|
||||
attrs: { checked: { default: null } },
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'li',
|
||||
getAttrs: (dom) => {
|
||||
const box = dom.querySelector(':scope > input[type=checkbox]')
|
||||
return { checked: box ? box.checked : null }
|
||||
}
|
||||
}
|
||||
],
|
||||
toDOM: (node) =>
|
||||
node.attrs.checked === null
|
||||
? ['li', 0]
|
||||
: [
|
||||
'li',
|
||||
{ class: 'visual-task-item', 'data-checked': node.attrs.checked ? 'true' : 'false' },
|
||||
0
|
||||
]
|
||||
},
|
||||
|
||||
definition_list: {
|
||||
content: '(definition_term | definition_description)+',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
/*
|
||||
Tight exactly as a bullet list is, and for the same reason: a blank line between a term and
|
||||
its definition makes `markdown-it-deflist` wrap each definition in a paragraph, which is a
|
||||
visible difference on the page. Writing every list out loose would put a `<p>` inside every
|
||||
`<dd>` on every wiki that ever used one.
|
||||
*/
|
||||
attrs: { tight: { default: true } },
|
||||
parseDOM: [{ tag: 'dl', getAttrs: (dom) => ({ tight: !dom.querySelector('dd > p') }) }],
|
||||
toDOM: () => ['dl', 0]
|
||||
},
|
||||
definition_term: {
|
||||
content: 'inline*',
|
||||
defining: true,
|
||||
parseDOM: [{ tag: 'dt' }],
|
||||
toDOM: () => ['dt', 0]
|
||||
},
|
||||
definition_description: {
|
||||
content: 'block+',
|
||||
defining: true,
|
||||
parseDOM: [{ tag: 'dd' }],
|
||||
toDOM: () => ['dd', 0]
|
||||
},
|
||||
|
||||
...tables,
|
||||
|
||||
/**
|
||||
* An MDC block component that holds page content: `::block-tabs`, `::block-tab`, `::block-steps`.
|
||||
*
|
||||
* These are the blocks that deliberately leave what they hold in the light DOM — see the note on
|
||||
* `block-tab` and `block-steps` in the repository's own documentation — so their bodies are the
|
||||
* article's own markdown and are edited as such, here, in place.
|
||||
*/
|
||||
block_container: {
|
||||
content: 'block+',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
attrs: { name: {}, blockAttrs: { default: null } },
|
||||
parseDOM: [
|
||||
{
|
||||
tag: '[data-block-container]',
|
||||
getAttrs: (dom) => ({ name: dom.getAttribute('data-block-container') })
|
||||
}
|
||||
],
|
||||
toDOM: (node) => ['div', { 'data-block-container': node.attrs.name }, 0]
|
||||
},
|
||||
|
||||
/**
|
||||
* Every other MDC block: an atom drawn by its own custom element.
|
||||
*
|
||||
* `body` is whatever sat between the fences, kept verbatim. For most blocks that is a fenced
|
||||
* source the block itself reads — a mermaid diagram, a KaTeX expression — and it is edited
|
||||
* through the block's own content editor rather than as document content, because it is not
|
||||
* markdown and the article's rules do not apply to it.
|
||||
*/
|
||||
block_component: {
|
||||
group: 'block',
|
||||
atom: true,
|
||||
defining: true,
|
||||
attrs: { name: {}, blockAttrs: { default: null }, body: { default: '' } },
|
||||
parseDOM: [
|
||||
{ tag: '[data-block]', getAttrs: (dom) => ({ name: dom.getAttribute('data-block') }) }
|
||||
],
|
||||
toDOM: (node) => ['div', { 'data-block': node.attrs.name }]
|
||||
},
|
||||
|
||||
/**
|
||||
* A footnote's body. Always written at the end of the document, wherever it was before: that is
|
||||
* where markdown-it reports them and where the editor's own insert puts them.
|
||||
*/
|
||||
footnote_definition: {
|
||||
content: 'block+',
|
||||
group: 'block',
|
||||
defining: true,
|
||||
attrs: { label: {} },
|
||||
parseDOM: [
|
||||
{
|
||||
tag: '[data-footnote]',
|
||||
getAttrs: (dom) => ({ label: dom.getAttribute('data-footnote') })
|
||||
}
|
||||
],
|
||||
toDOM: (node) => ['div', { 'data-footnote': node.attrs.label, class: 'visual-footnote' }, 0]
|
||||
},
|
||||
|
||||
/**
|
||||
* An abbreviation definition, `*[HTML]: Hyper Text Markup Language`.
|
||||
*
|
||||
* An atom with no content of its own, because the definition is a declaration rather than part of
|
||||
* the prose: markdown-it consumes the line entirely and leaves no token behind, so the editor is
|
||||
* the only thing that will ever show it to the author who wrote it.
|
||||
*/
|
||||
abbreviation: {
|
||||
group: 'block',
|
||||
atom: true,
|
||||
attrs: { label: {}, title: { default: '' } },
|
||||
parseDOM: [{ tag: '[data-abbreviation]' }],
|
||||
toDOM: (node) => [
|
||||
'div',
|
||||
{ 'data-abbreviation': '', class: 'visual-abbreviation' },
|
||||
`${node.attrs.label} — ${node.attrs.title}`
|
||||
]
|
||||
},
|
||||
|
||||
/**
|
||||
* Markdown this schema has no node for, kept exactly as it was written.
|
||||
*
|
||||
* The escape hatch that makes opening a page in this editor safe. Raw HTML lands here, and so does
|
||||
* anything a future plugin adds to the pipeline before this file learns about it: it is shown as
|
||||
* source, it cannot be edited into something else by accident, and it is written back byte for
|
||||
* byte. Without it, "the editor did not understand this" and "the editor deleted this" would be
|
||||
* the same event.
|
||||
*/
|
||||
raw_block: {
|
||||
group: 'block',
|
||||
atom: true,
|
||||
defining: true,
|
||||
attrs: { source: { default: '' } },
|
||||
parseDOM: [{ tag: 'div[data-raw]', getAttrs: (dom) => ({ source: dom.textContent }) }],
|
||||
toDOM: (node) => ['div', { 'data-raw': '', class: 'visual-raw' }, node.attrs.source]
|
||||
},
|
||||
|
||||
text: { group: 'inline' },
|
||||
|
||||
image: {
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
draggable: true,
|
||||
attrs: {
|
||||
src: {},
|
||||
alt: { default: null },
|
||||
title: { default: null },
|
||||
width: { default: null },
|
||||
height: { default: null },
|
||||
...mdAttrs
|
||||
},
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'img[src]',
|
||||
getAttrs: (dom) => ({
|
||||
src: dom.getAttribute('src'),
|
||||
alt: dom.getAttribute('alt'),
|
||||
title: dom.getAttribute('title'),
|
||||
width: dom.getAttribute('width'),
|
||||
height: dom.getAttribute('height')
|
||||
})
|
||||
}
|
||||
],
|
||||
toDOM: (node) => ['img', node.attrs]
|
||||
},
|
||||
|
||||
/**
|
||||
* A line break inside a paragraph — and which of the two kinds markdown has.
|
||||
*
|
||||
* `soft` is a plain newline, `hard` the backslash that forces a break wherever it is read. They
|
||||
* are kept apart because a site with `lineBreaks` on draws BOTH as a `<br>`: flattening a soft
|
||||
* break into a space, which is what a CommonMark serialiser does, would quietly delete a visible
|
||||
* line break from every page written on such a site.
|
||||
*/
|
||||
hard_break: {
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
selectable: false,
|
||||
attrs: { markup: { default: 'hard' } },
|
||||
parseDOM: [{ tag: 'br' }],
|
||||
toDOM: () => ['br']
|
||||
},
|
||||
|
||||
/**
|
||||
* Raw inline HTML, kept as written. The inline counterpart of `raw_block`, and there for the same
|
||||
* reason: an author who wrote `<kbd>` in the middle of a sentence gets it back.
|
||||
*/
|
||||
raw_inline: {
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
attrs: { source: { default: '' } },
|
||||
parseDOM: [
|
||||
{ tag: 'span[data-raw-inline]', getAttrs: (dom) => ({ source: dom.textContent }) }
|
||||
],
|
||||
toDOM: (node) => [
|
||||
'span',
|
||||
{ 'data-raw-inline': '', class: 'visual-raw-inline' },
|
||||
node.attrs.source
|
||||
]
|
||||
},
|
||||
|
||||
/**
|
||||
* An emoji shortcode. The character is what is drawn in the editor; the shortcode is what goes
|
||||
* back into the source, because that is what the author typed and what the picker inserts.
|
||||
*/
|
||||
emoji: {
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
attrs: { shortcode: {}, char: { default: '' } },
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'span[data-emoji]',
|
||||
getAttrs: (dom) => ({ shortcode: dom.getAttribute('data-emoji'), char: dom.textContent })
|
||||
}
|
||||
],
|
||||
toDOM: (node) => ['span', { 'data-emoji': node.attrs.shortcode }, node.attrs.char]
|
||||
},
|
||||
|
||||
/** An Iconify reference written as a shortcode, `:mdi:home:`. */
|
||||
icon: {
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
attrs: { reference: {} },
|
||||
parseDOM: [
|
||||
{ tag: 'iconify-icon', getAttrs: (dom) => ({ reference: dom.getAttribute('icon') }) }
|
||||
],
|
||||
toDOM: (node) => ['iconify-icon', { icon: node.attrs.reference }]
|
||||
},
|
||||
|
||||
footnote_ref: {
|
||||
inline: true,
|
||||
group: 'inline',
|
||||
atom: true,
|
||||
attrs: { label: {} },
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'sup[data-footnote-ref]',
|
||||
getAttrs: (dom) => ({ label: dom.getAttribute('data-footnote-ref') })
|
||||
}
|
||||
],
|
||||
toDOM: (node) => [
|
||||
'sup',
|
||||
{ 'data-footnote-ref': node.attrs.label, class: 'visual-footnote-ref' },
|
||||
`[${node.attrs.label}]`
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
marks: {
|
||||
/*
|
||||
Ordered deliberately: ProseMirror serialises a mark set in the order the marks are declared, and
|
||||
the serialiser writes their delimiters in that order too. Link outermost, then the emphases,
|
||||
then code — which is the order these read best in and the order markdown-it parses back
|
||||
unambiguously.
|
||||
*/
|
||||
/**
|
||||
* A link, and whatever `{…}` was stuck to it.
|
||||
*
|
||||
* `target="_blank"` is the one that matters and the reason this carries attributes at all: it is
|
||||
* how both editors write "open in a new tab", and a link mark that could not hold it would drop it
|
||||
* from every page that had one, the first time the page was opened here and saved.
|
||||
*/
|
||||
link: {
|
||||
attrs: { href: {}, title: { default: null }, ...mdAttrs },
|
||||
inclusive: false,
|
||||
parseDOM: [
|
||||
{
|
||||
tag: 'a[href]',
|
||||
getAttrs: (dom) => ({ href: dom.getAttribute('href'), title: dom.getAttribute('title') })
|
||||
}
|
||||
],
|
||||
toDOM: (node) => [
|
||||
'a',
|
||||
{ ...domAttrs(node), href: node.attrs.href, title: node.attrs.title },
|
||||
0
|
||||
]
|
||||
},
|
||||
|
||||
/**
|
||||
* An MDC inline span, `[text]{.cls}`.
|
||||
*
|
||||
* Its attributes are read off the `mdc_inline_props` token that FOLLOWS the span's close, which is
|
||||
* where MDC puts them — see the parser.
|
||||
*/
|
||||
span: {
|
||||
attrs: { ...mdAttrs },
|
||||
parseDOM: [{ tag: 'span[data-md-span]' }],
|
||||
toDOM: (node) => ['span', { ...domAttrs(node), 'data-md-span': '' }, 0]
|
||||
},
|
||||
|
||||
strong: {
|
||||
parseDOM: [
|
||||
{ tag: 'strong' },
|
||||
{ tag: 'b', getAttrs: (dom) => dom.style.fontWeight !== 'normal' && null },
|
||||
{ style: 'font-weight=400', clearMark: (mark) => mark.type.name === 'strong' },
|
||||
{
|
||||
style: 'font-weight',
|
||||
getAttrs: (value) => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null
|
||||
}
|
||||
],
|
||||
toDOM: () => ['strong', 0]
|
||||
},
|
||||
|
||||
em: {
|
||||
parseDOM: [{ tag: 'i' }, { tag: 'em' }, { style: 'font-style=italic' }],
|
||||
toDOM: () => ['em', 0]
|
||||
},
|
||||
|
||||
/**
|
||||
* `_text_`, which this pipeline draws as an underline rather than as emphasis when the site turns
|
||||
* `underline` on.
|
||||
*
|
||||
* A mark of its own, and not a cosmetic detail: with that setting on, rewriting `_x_` as `*x*`
|
||||
* would change an underline into an italic. With it off the parser never produces this mark, so
|
||||
* `_x_` normalises to `*x*` like any other spelling choice.
|
||||
*/
|
||||
underline: {
|
||||
parseDOM: [{ tag: 'u' }, { style: 'text-decoration=underline' }],
|
||||
toDOM: () => ['u', 0]
|
||||
},
|
||||
|
||||
strike: {
|
||||
parseDOM: [{ tag: 's' }, { tag: 'del' }, { style: 'text-decoration=line-through' }],
|
||||
toDOM: () => ['s', 0]
|
||||
},
|
||||
|
||||
mark: {
|
||||
parseDOM: [{ tag: 'mark' }],
|
||||
toDOM: () => ['mark', 0]
|
||||
},
|
||||
|
||||
sub: {
|
||||
parseDOM: [{ tag: 'sub' }],
|
||||
toDOM: () => ['sub', 0]
|
||||
},
|
||||
|
||||
sup: {
|
||||
parseDOM: [{ tag: 'sup' }],
|
||||
toDOM: () => ['sup', 0]
|
||||
},
|
||||
|
||||
/**
|
||||
* An abbreviation the renderer matched, NOT something the author marked up.
|
||||
*
|
||||
* It exists so the editor can show what a definition elsewhere in the page is doing, and it is
|
||||
* deliberately absent from the serialiser: the source of an abbreviation is its `*[X]: …` line,
|
||||
* and writing anything at the point of use would invent markup that was never there.
|
||||
*/
|
||||
abbr: {
|
||||
attrs: { title: { default: '' } },
|
||||
parseDOM: [{ tag: 'abbr[title]', getAttrs: (dom) => ({ title: dom.getAttribute('title') }) }],
|
||||
toDOM: (node) => ['abbr', { title: node.attrs.title }, 0]
|
||||
},
|
||||
|
||||
/**
|
||||
* A code span.
|
||||
*
|
||||
* Deliberately excludes nothing. `**\`name\`**` is ordinary markdown and markdown-it parses it as
|
||||
* a strong containing a code span, so a mark that refused to share its range would drop the
|
||||
* strong on the way in and write the page back without it. The declaration order above is what
|
||||
* keeps this innermost, which is the only spelling that parses back to the same thing.
|
||||
*/
|
||||
code: {
|
||||
code: true,
|
||||
parseDOM: [{ tag: 'code' }],
|
||||
toDOM: () => ['code', 0]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
/** The DOM attributes for a node carrying `mdAttrs`, so the editor draws what the page will. */
|
||||
function domAttrs(node) {
|
||||
const attrs = node.attrs.mdAttrs
|
||||
if (!attrs) {
|
||||
return {}
|
||||
}
|
||||
const out = {}
|
||||
if (attrs.id) {
|
||||
out.id = attrs.id
|
||||
}
|
||||
if (attrs.class) {
|
||||
out.class = attrs.class
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
import { MarkdownSerializer } from 'prosemirror-markdown'
|
||||
|
||||
import { KINDS } from '@/renderers/modules/github-alerts'
|
||||
|
||||
import { writeMdAttrs } from './schema'
|
||||
|
||||
/**
|
||||
* A ProseMirror document back into markdown.
|
||||
*
|
||||
* The exacting half. Parsing a construct wrongly shows the author something odd and they can see it;
|
||||
* serialising one wrongly writes it into the page, and the mistake only surfaces the next time
|
||||
* somebody opens the file. So every rule here is written to produce source that the site's own
|
||||
* markdown-it reads back as the document it came from — which is exactly what `verifyRoundTrip` in
|
||||
* `convert.js` checks, rather than trusting this file to be right.
|
||||
*
|
||||
* What is deliberately NOT preserved is spelling: which of `-`, `*` or `+` starts a bullet is kept
|
||||
* because a page full of one of them would otherwise become a page full of another, but indentation,
|
||||
* table padding and the spacing inside a `{…}` are normalised. That is the cost of the format being a
|
||||
* view rather than the storage, and it is why converting a page rewrites its source once, up front,
|
||||
* as its own history version.
|
||||
*/
|
||||
|
||||
/** The fence a block component needs: deep enough that nothing inside it closes it early. */
|
||||
function fenceFor(node) {
|
||||
return ':'.repeat(2 + blockDepth(node))
|
||||
}
|
||||
|
||||
/**
|
||||
* How deeply block components nest inside this one.
|
||||
*
|
||||
* MDC closes a block with the fence it was opened with, so a `::block-tabs` holding `::block-tab`
|
||||
* children has to be written with three colons or the first child's `::` would close the parent. The
|
||||
* depth is counted rather than assumed, so a tabset holding a panel holding a block still comes out
|
||||
* right.
|
||||
*/
|
||||
function blockDepth(node) {
|
||||
let deepest = 0
|
||||
node.forEach((child) => {
|
||||
const own = child.type.name === 'block_component' || child.type.name === 'block_container'
|
||||
const inner = blockDepth(child) + (own ? 1 : 0)
|
||||
deepest = Math.max(deepest, inner)
|
||||
})
|
||||
return deepest
|
||||
}
|
||||
|
||||
/** `::block-name{a="b"}`, the opening line both block nodes share. */
|
||||
function blockOpening(node, fence) {
|
||||
const attrs = node.attrs.blockAttrs ?? {}
|
||||
const written = Object.entries(attrs)
|
||||
.filter(([, value]) => value !== null && value !== undefined && value !== '')
|
||||
// -> A double quote would close the attribute and MDC has no escape for it, exactly as
|
||||
// `helpers/blocks.js` writes them
|
||||
.map(([name, value]) => `${name}="${String(value).replaceAll('"', "'")}"`)
|
||||
const suffix = written.length > 0 ? `{${written.join(' ')}}` : ''
|
||||
return `${fence}block-${node.attrs.name}${suffix}`
|
||||
}
|
||||
|
||||
/**
|
||||
* A table row as one pipe-delimited line.
|
||||
*
|
||||
* Cells are rendered as inline markdown: a pipe table has one line per row, so a cell holding more
|
||||
* than a paragraph cannot be written as one. Rather than refuse, the paragraphs are joined with a
|
||||
* space and the round-trip check is what tells the author the page no longer renders the same — which
|
||||
* is the honest failure for a construct this format genuinely cannot express.
|
||||
*/
|
||||
function tableRow(state, row) {
|
||||
const cells = []
|
||||
row.forEach((cell) => {
|
||||
const parts = []
|
||||
cell.forEach((child) => {
|
||||
parts.push(inlineOf(state, child))
|
||||
})
|
||||
/*
|
||||
A literal pipe would end the cell, so it is escaped here rather than by the text rule, which has
|
||||
no idea it is inside a table. One that is already escaped is left alone: a code span may hold
|
||||
`a\|b` as its literal text, and escaping the escape would put a second backslash on the page.
|
||||
*/
|
||||
cells.push(
|
||||
parts
|
||||
.join(' ')
|
||||
.replace(/\\?\|/g, (match) => (match === '|' ? '\\|' : match))
|
||||
.trim()
|
||||
)
|
||||
})
|
||||
return `| ${cells.join(' | ')} |`
|
||||
}
|
||||
|
||||
/**
|
||||
* One block's inline content as a string, rendered through a throwaway serializer state.
|
||||
*
|
||||
* `MarkdownSerializerState` writes into an accumulator it owns, and a table cell has to be turned
|
||||
* into text before the row it belongs to can be written at all — so the cell is rendered on a state
|
||||
* of its own and the result taken out of it.
|
||||
*/
|
||||
function inlineOf(state, node) {
|
||||
if (node.isTextblock) {
|
||||
const sub = new state.constructor(state.nodes, state.marks, state.options)
|
||||
sub.renderInline(node)
|
||||
return sub.out.trim()
|
||||
}
|
||||
return node.textContent
|
||||
}
|
||||
|
||||
/** The delimiter row, which is also where a pipe table says how each column is set. */
|
||||
function alignmentRow(row) {
|
||||
const cells = []
|
||||
row.forEach((cell) => {
|
||||
const align = cell.attrs.align
|
||||
cells.push(
|
||||
align === 'center' ? ':---:' : align === 'right' ? '---:' : align === 'left' ? ':---' : '---'
|
||||
)
|
||||
})
|
||||
return `| ${cells.join(' | ')} |`
|
||||
}
|
||||
|
||||
const nodes = {
|
||||
paragraph(state, node) {
|
||||
state.renderInline(node)
|
||||
writeAttrsSuffix(state, node)
|
||||
state.closeBlock(node)
|
||||
},
|
||||
|
||||
heading(state, node) {
|
||||
state.write(`${state.repeat('#', node.attrs.level)} `)
|
||||
state.renderInline(node, false)
|
||||
writeAttrsSuffix(state, node)
|
||||
state.closeBlock(node)
|
||||
},
|
||||
|
||||
blockquote(state, node) {
|
||||
state.wrapBlock('> ', null, node, () => state.renderContent(node))
|
||||
},
|
||||
|
||||
/**
|
||||
* `> [!NOTE] Title`, then the body.
|
||||
*
|
||||
* The marker is written as the first line INSIDE the quote, which is what makes it the alert's own
|
||||
* line rather than a paragraph of the body — `wrapBlock` puts the `> ` in front of everything it
|
||||
* writes, the marker included.
|
||||
*/
|
||||
alert(state, node) {
|
||||
const kind = KINDS.has(node.attrs.kind) ? node.attrs.kind : 'note'
|
||||
state.wrapBlock('> ', null, node, () => {
|
||||
state.write(`[!${kind.toUpperCase()}]${node.attrs.title ? ` ${node.attrs.title}` : ''}`)
|
||||
state.ensureNewLine()
|
||||
state.renderContent(node)
|
||||
})
|
||||
},
|
||||
|
||||
code_block(state, node) {
|
||||
if (node.attrs.indented) {
|
||||
state.wrapBlock(' ', null, node, () => state.text(node.textContent, false))
|
||||
return
|
||||
}
|
||||
// -> Long enough to survive any run of backticks in the code itself
|
||||
const runs = node.textContent.match(/`{3,}/gm)
|
||||
const fence = runs ? `${runs.sort().at(-1)}\`` : '```'
|
||||
state.write(fence + (node.attrs.params || '') + '\n')
|
||||
state.text(node.textContent, false)
|
||||
state.write('\n')
|
||||
state.write(fence)
|
||||
state.closeBlock(node)
|
||||
},
|
||||
|
||||
horizontal_rule(state, node) {
|
||||
state.write(node.attrs.markup || '---')
|
||||
state.closeBlock(node)
|
||||
},
|
||||
|
||||
bullet_list(state, node) {
|
||||
state.renderList(node, ' ', () => `${node.attrs.markup || '-'} `)
|
||||
writeListAttrs(state, node)
|
||||
},
|
||||
|
||||
ordered_list(state, node) {
|
||||
const start = node.attrs.order ?? 1
|
||||
const width = String(start + node.childCount - 1).length
|
||||
const space = state.repeat(' ', width + 2)
|
||||
state.renderList(node, space, (index) => {
|
||||
const number = String(start + index)
|
||||
return `${state.repeat(' ', width - number.length)}${number}${node.attrs.markup || '.'} `
|
||||
})
|
||||
writeListAttrs(state, node)
|
||||
},
|
||||
|
||||
/**
|
||||
* One item, with the `[x]` marker in front of it when it is a task.
|
||||
*
|
||||
* Written by hand rather than folded into the list's own delimiter, because `renderList` gives every
|
||||
* item the same one and whether an item is ticked is a property of the item.
|
||||
*/
|
||||
list_item(state, node) {
|
||||
if (node.attrs.checked !== null) {
|
||||
state.write(node.attrs.checked ? '[x] ' : '[ ] ')
|
||||
}
|
||||
state.renderContent(node)
|
||||
},
|
||||
|
||||
/**
|
||||
* A definition list.
|
||||
*
|
||||
* Written by hand rather than through `renderContent`, which closes every child as its own block and
|
||||
* so puts a blank line everywhere. Two different gaps are needed: a term starts a new group and must
|
||||
* be separated from the definition above it, or markdown reads it as another line of that
|
||||
* definition — while a definition follows its own term directly, which is what keeps the list tight.
|
||||
*/
|
||||
definition_list(state, node) {
|
||||
node.forEach((child, _offset, index) => {
|
||||
if (index > 0) {
|
||||
const startsGroup = child.type.name === 'definition_term'
|
||||
state.flushClose(startsGroup || !node.attrs.tight ? 2 : 1)
|
||||
}
|
||||
state.render(child, node, index)
|
||||
})
|
||||
state.closeBlock(node)
|
||||
},
|
||||
|
||||
definition_term(state, node) {
|
||||
state.renderInline(node)
|
||||
state.closeBlock(node)
|
||||
},
|
||||
definition_description(state, node) {
|
||||
// -> `: ` opens the definition and everything under it is indented to clear the marker
|
||||
state.wrapBlock(' ', ': ', node, () => state.renderContent(node))
|
||||
},
|
||||
|
||||
table(state, node) {
|
||||
node.forEach((row, _offset, index) => {
|
||||
state.write(tableRow(state, row))
|
||||
state.ensureNewLine()
|
||||
/*
|
||||
The delimiter row goes under the first row when that row is the header — which in markdown is
|
||||
the only place it can go, and the only thing that makes the table a table.
|
||||
*/
|
||||
if (index === 0) {
|
||||
state.write(alignmentRow(row))
|
||||
state.ensureNewLine()
|
||||
}
|
||||
})
|
||||
state.closeBlock(node)
|
||||
},
|
||||
|
||||
block_container(state, node) {
|
||||
const fence = fenceFor(node)
|
||||
state.write(blockOpening(node, fence))
|
||||
state.ensureNewLine()
|
||||
state.renderContent(node)
|
||||
state.ensureNewLine()
|
||||
state.write(fence)
|
||||
state.closeBlock(node)
|
||||
},
|
||||
|
||||
block_component(state, node) {
|
||||
const fence = fenceFor(node)
|
||||
state.write(blockOpening(node, fence))
|
||||
state.ensureNewLine()
|
||||
if (node.attrs.body) {
|
||||
state.text(node.attrs.body, false)
|
||||
state.ensureNewLine()
|
||||
}
|
||||
state.write(fence)
|
||||
state.closeBlock(node)
|
||||
},
|
||||
|
||||
footnote_definition(state, node) {
|
||||
state.wrapBlock(' ', `[^${node.attrs.label}]: `, node, () => state.renderContent(node))
|
||||
},
|
||||
|
||||
abbreviation(state, node) {
|
||||
state.write(`*[${node.attrs.label}]: ${node.attrs.title}`)
|
||||
state.closeBlock(node)
|
||||
},
|
||||
|
||||
/** Kept exactly as it was read — the whole point of the node. */
|
||||
raw_block(state, node) {
|
||||
state.text(node.attrs.source, false)
|
||||
state.closeBlock(node)
|
||||
},
|
||||
|
||||
image(state, node) {
|
||||
const src = node.attrs.src.replace(/[()]/g, '\\$&')
|
||||
const title = node.attrs.title ? ` "${node.attrs.title.replace(/"/g, '\\"')}"` : ''
|
||||
/*
|
||||
`markdown-it-imsize`'s own syntax, which is a suffix on the destination. Either half may be
|
||||
missing -- `=300x`, `=x200` -- and both are written, because a height on its own is a size the
|
||||
plugin reads and dropping it would resize the picture on a save nobody asked to resize it in.
|
||||
*/
|
||||
const size =
|
||||
node.attrs.width || node.attrs.height
|
||||
? ` =${node.attrs.width ?? ''}x${node.attrs.height ?? ''}`
|
||||
: ''
|
||||
state.write(
|
||||
`${writeMdAttrs(node.attrs.mdAttrs)}`
|
||||
)
|
||||
},
|
||||
|
||||
/**
|
||||
* A line break, as whichever of the two kinds it was.
|
||||
*
|
||||
* A trailing break is dropped: markdown has nowhere to put one, and writing it would leave a stray
|
||||
* backslash at the end of a paragraph.
|
||||
*/
|
||||
hard_break(state, node, parent, index) {
|
||||
for (let i = index + 1; i < parent.childCount; i++) {
|
||||
if (parent.child(i).type !== node.type) {
|
||||
state.write(node.attrs.markup === 'soft' ? '\n' : '\\\n')
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
emoji(state, node) {
|
||||
state.write(`:${node.attrs.shortcode}:`)
|
||||
},
|
||||
|
||||
icon(state, node) {
|
||||
state.write(`:${node.attrs.reference}:`)
|
||||
},
|
||||
|
||||
footnote_ref(state, node) {
|
||||
state.write(`[^${node.attrs.label}]`)
|
||||
},
|
||||
|
||||
raw_inline(state, node) {
|
||||
state.write(node.attrs.source)
|
||||
},
|
||||
|
||||
text(state, node) {
|
||||
state.text(node.text, !state.inAutolink)
|
||||
}
|
||||
}
|
||||
|
||||
const marks = {
|
||||
em: { open: '*', close: '*', mixable: true, expelEnclosingWhitespace: true },
|
||||
underline: { open: '_', close: '_', mixable: true, expelEnclosingWhitespace: true },
|
||||
strong: { open: '**', close: '**', mixable: true, expelEnclosingWhitespace: true },
|
||||
strike: { open: '~~', close: '~~', mixable: true, expelEnclosingWhitespace: true },
|
||||
mark: { open: '==', close: '==', mixable: true, expelEnclosingWhitespace: true },
|
||||
sub: { open: '~', close: '~', mixable: true, expelEnclosingWhitespace: true },
|
||||
sup: { open: '^', close: '^', mixable: true, expelEnclosingWhitespace: true },
|
||||
|
||||
/** MDC's inline span, whose attributes hang off the closing bracket. */
|
||||
span: {
|
||||
open: '[',
|
||||
close: (_state, mark) => `]${writeMdAttrs(mark.attrs.mdAttrs)}`,
|
||||
mixable: false
|
||||
},
|
||||
|
||||
/**
|
||||
* An abbreviation at the point of use, which has no source of its own.
|
||||
*
|
||||
* The mark exists so the editor can show what a `*[X]: …` line elsewhere is doing; the definition is
|
||||
* what gets written, by the `abbreviation` node. Emitting anything here would invent markup the
|
||||
* author never typed — and it would be re-applied on the next parse, so it would grow every time.
|
||||
*/
|
||||
abbr: { open: '', close: '', mixable: true },
|
||||
|
||||
link: {
|
||||
open: '[',
|
||||
close: (_state, mark) =>
|
||||
`](${mark.attrs.href.replace(/[()"]/g, '\\$&')}${
|
||||
mark.attrs.title ? ` "${mark.attrs.title.replace(/"/g, '\\"')}"` : ''
|
||||
})${writeMdAttrs(mark.attrs.mdAttrs)}`,
|
||||
mixable: true
|
||||
},
|
||||
|
||||
code: {
|
||||
open: (_state, _mark, parent, index) => backticksFor(parent.child(index), -1),
|
||||
close: (_state, _mark, parent, index) => backticksFor(parent.child(index - 1), 1),
|
||||
escape: false
|
||||
}
|
||||
}
|
||||
|
||||
/** A run of backticks long enough to delimit a code span containing backticks of its own. */
|
||||
function backticksFor(node, side) {
|
||||
const pattern = /`+/g
|
||||
let length = 0
|
||||
if (node.isText) {
|
||||
let match
|
||||
while ((match = pattern.exec(node.text))) {
|
||||
length = Math.max(length, match[0].length)
|
||||
}
|
||||
}
|
||||
let result = length > 0 && side > 0 ? ' `' : '`'
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += '`'
|
||||
}
|
||||
if (length > 0 && side < 0) {
|
||||
result += ' '
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/** The `{…}` suffix a block carries, written on the end of its last line. */
|
||||
function writeAttrsSuffix(state, node) {
|
||||
const suffix = writeMdAttrs(node.attrs.mdAttrs)
|
||||
if (suffix) {
|
||||
state.write(` ${suffix}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same, for a list — which wears its attributes on a line of its own underneath.
|
||||
*
|
||||
* `markdown-it-attrs` reads a brace at the start of the line after a list as belonging to the list,
|
||||
* and there is nowhere else to put it: the end of the last item's line is that ITEM's attributes.
|
||||
*/
|
||||
function writeListAttrs(state, node) {
|
||||
const suffix = writeMdAttrs(node.attrs.mdAttrs)
|
||||
if (suffix) {
|
||||
state.write(suffix)
|
||||
state.closeBlock(node)
|
||||
}
|
||||
}
|
||||
|
||||
export const serializer = new MarkdownSerializer(nodes, marks)
|
||||
|
||||
/**
|
||||
* @param {import('prosemirror-model').Node} doc
|
||||
* @returns {string} The page source, with the single trailing newline a file ends on.
|
||||
*/
|
||||
export function serialize(doc) {
|
||||
const text = serializer.serialize(doc, { tightLists: true })
|
||||
return text.endsWith('\n') ? text : `${text}\n`
|
||||
}
|
||||
@@ -61,3 +61,28 @@ export function splitLocalePath(urlPath, prefixes) {
|
||||
// -> `/fr` alone is the French home page, which is `/` under the prefix
|
||||
return { locale, path: slash < 0 ? '/' : urlPath.slice(slash) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Files a browser or a crawler asks for at the root by convention, and the root segments this app's
|
||||
* own router owns despite carrying no leading underscore.
|
||||
*
|
||||
* Mirrors `RESERVED_ROOT_FILES` and `RESERVED_ROOT_PATHS` in the backend's `helpers/common.ts`.
|
||||
*/
|
||||
const RESERVED_ROOT = new Set(['favicon.ico', 'robots.txt', 'sitemap.xml', 'login', 'a', 'i'])
|
||||
|
||||
/**
|
||||
* Whether a URL addresses the page tree rather than the application itself.
|
||||
*
|
||||
* Everything the app mounts for itself sits under `/_…`, which is what makes this a prefix test
|
||||
* rather than a list — except for the sign-in form and the two short links to a page, `/a/<alias>`
|
||||
* and `/i/<id>`, which predate that convention.
|
||||
*
|
||||
* Mirrors `isPageUrl` in the backend's `helpers/common.ts`, and has to: the two answer the same
|
||||
* question about the same URL, one for a request that reaches the server and one for a link the
|
||||
* router follows on its own. Where they disagreed, `/login` was a page to one of them — locale
|
||||
* prefixed into `/en/login`, which matches no route at all.
|
||||
*/
|
||||
export function isPagePath(urlPath) {
|
||||
const firstSegment = (urlPath.split('/')[1] ?? '').toLowerCase()
|
||||
return !firstSegment.startsWith('_') && !RESERVED_ROOT.has(firstSegment)
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { initializeEventBus } from './boot/eventbus'
|
||||
import { initializeExternals } from './boot/externals'
|
||||
import { initializeI18n } from './boot/i18n'
|
||||
import { initializeIconify } from './boot/iconify'
|
||||
import { initializeMonaco } from './boot/monaco'
|
||||
import { initializeTemporal } from './boot/temporal'
|
||||
import { initializeHairlines } from './helpers/hairline'
|
||||
|
||||
@@ -30,6 +31,8 @@ initializeApi()
|
||||
initializeComponents(app)
|
||||
initializeEventBus()
|
||||
initializeIconify()
|
||||
// -> Before any editor is opened, which is the first thing that asks for a worker
|
||||
initializeMonaco()
|
||||
initializeExternals(router, store)
|
||||
initializeI18n(app, store)
|
||||
// The server's copy of the page, for clients that never get this far -- see
|
||||
|
||||
@@ -159,7 +159,13 @@ watch(
|
||||
async function load() {
|
||||
state.loading++
|
||||
try {
|
||||
state.blocks = (await API_CLIENT.get(`sites/${adminStore.currentSiteId}/blocks`).json()) ?? []
|
||||
/*
|
||||
Child blocks are dropped: the API lists them so an editor can build a form from the props they
|
||||
declare, but there is nothing here to do with one. `block-tab` has no row, cannot be switched off
|
||||
independently of the tabset it sits in, and cannot be deleted.
|
||||
*/
|
||||
const all = (await API_CLIENT.get(`sites/${adminStore.currentSiteId}/blocks`).json()) ?? []
|
||||
state.blocks = all.filter((block) => !block.isChild)
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
|
||||
@@ -129,7 +129,7 @@ const state = reactive({
|
||||
channel: false,
|
||||
markdown: false,
|
||||
redirect: true,
|
||||
wysiwyg: false
|
||||
visual: false
|
||||
}
|
||||
})
|
||||
const editors = reactive([
|
||||
@@ -171,9 +171,8 @@ const editors = reactive([
|
||||
useRendering: false
|
||||
},
|
||||
{
|
||||
id: 'wysiwyg',
|
||||
id: 'visual',
|
||||
icon: 'google-presentation',
|
||||
isDisabled: true,
|
||||
useRendering: true
|
||||
}
|
||||
])
|
||||
@@ -197,7 +196,7 @@ async function load() {
|
||||
const data = resp?.editors
|
||||
state.config.asciidoc = data?.asciidoc?.isActive ?? false
|
||||
state.config.markdown = data?.markdown?.isActive ?? false
|
||||
state.config.wysiwyg = data?.wysiwyg?.isActive ?? false
|
||||
state.config.visual = data?.visual?.isActive ?? false
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
@@ -217,7 +216,7 @@ async function save() {
|
||||
editors: {
|
||||
asciidoc: { isActive: state.config.asciidoc },
|
||||
markdown: { isActive: state.config.markdown },
|
||||
wysiwyg: { isActive: state.config.wysiwyg }
|
||||
visual: { isActive: state.config.visual }
|
||||
}
|
||||
}
|
||||
}).json()
|
||||
@@ -231,7 +230,7 @@ async function save() {
|
||||
editors: {
|
||||
asciidoc: state.config.asciidoc,
|
||||
markdown: state.config.markdown,
|
||||
wysiwyg: state.config.wysiwyg
|
||||
visual: state.config.visual
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -397,7 +397,7 @@ import { notify } from '@/composables/notify'
|
||||
import { withViewTransition } from '@/composables/viewTransition'
|
||||
import { loading } from '@/composables/loading'
|
||||
import { scrollToAnchor, scrollToAnchorWhenReady } from '@/helpers/anchors'
|
||||
import { splitLocalePath } from '@/helpers/pagePaths'
|
||||
import { isPagePath, splitLocalePath } from '@/helpers/pagePaths'
|
||||
import {
|
||||
enhanceRenderedContent,
|
||||
resolveContentClick,
|
||||
@@ -440,14 +440,14 @@ const editorComponents = {
|
||||
loader: () => import('../components/EditorMarkdown.vue'),
|
||||
loadingComponent: LoadingGeneric
|
||||
}),
|
||||
visual: defineAsyncComponent({
|
||||
loader: () => import('../components/EditorVisual.vue'),
|
||||
loadingComponent: LoadingGeneric
|
||||
}),
|
||||
redirect: defineAsyncComponent({
|
||||
loader: () => import('../components/EditorRedirect.vue'),
|
||||
loadingComponent: LoadingGeneric
|
||||
})
|
||||
// wysiwyg: defineAsyncComponent({
|
||||
// loader: () => import('../components/EditorWysiwyg.vue'),
|
||||
// loadingComponent: LoadingGeneric
|
||||
// })
|
||||
}
|
||||
|
||||
// STORES
|
||||
@@ -801,23 +801,46 @@ watch(
|
||||
return
|
||||
}
|
||||
|
||||
// -> Enter Edit Mode?
|
||||
/*
|
||||
-> Enter Edit Mode?
|
||||
This is the only way into the editor: the Edit button navigates here rather than opening one
|
||||
where it stands, so a reload, a shared link and a press of the button all arrive by the same
|
||||
route -- and leaving is a navigation like any other.
|
||||
|
||||
Wrapped, because it is now reached by a click and not only by a hand-typed URL. `pageEdit`
|
||||
throws for a page that is gone or that this reader may not open, and an unguarded throw left the
|
||||
loading overlay up for ever with no way back.
|
||||
*/
|
||||
if (newValue.startsWith('/_edit')) {
|
||||
if (!route.params.pagePath) {
|
||||
return router.replace('/')
|
||||
}
|
||||
loading.show()
|
||||
await pageStore.pageEdit({
|
||||
path: route.params.pagePath,
|
||||
locale: route.query.locale,
|
||||
fromNavigate: true
|
||||
})
|
||||
loading.hide()
|
||||
try {
|
||||
await pageStore.pageEdit({
|
||||
path: route.params.pagePath,
|
||||
locale: route.query.locale,
|
||||
fromNavigate: true
|
||||
})
|
||||
} catch (err) {
|
||||
editorStore.closeEditor()
|
||||
notify({ type: 'negative', message: t('editor.openFailed'), caption: err.message })
|
||||
// -> Onto the page itself, which says what happened properly: not found, or the unauthorized
|
||||
// screen with its offer to sign in as somebody else
|
||||
router.replace(`/${route.params.pagePath}`)
|
||||
} finally {
|
||||
loading.hide()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// -> Moving to a non-page path? Ignore
|
||||
if (newValue.startsWith('/_')) {
|
||||
/*
|
||||
-> Moving to a non-page path? Ignore
|
||||
`isPagePath` and not a bare `/_` test: the sign-in form and `/a/<alias>` are the app's own
|
||||
screens at root paths that carry no underscore, and treating either as a page had this load one
|
||||
by the name of the route.
|
||||
*/
|
||||
if (!isPagePath(newValue)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -855,25 +878,29 @@ watch(
|
||||
return isFirstRun ? update() : withViewTransition(update)
|
||||
}
|
||||
|
||||
/*
|
||||
Walking away from the editor closes it -- BEFORE the page is loaded rather than after.
|
||||
|
||||
It used to sit below the load, inside the same `try`, so a destination that would not load left
|
||||
the editor standing: the reader got the not-found screen, or the welcome overlay, with an
|
||||
editor still active behind it and a page store describing something else entirely. Every
|
||||
failure of the load was also a failure to close.
|
||||
|
||||
`closeEditor` and not a patch of `isActive`, because closing is more fields than that -- see the
|
||||
store. `mode` above all: left on `create`, it goes on claiming a page is being written long
|
||||
after the reader has moved on to reading one, and everything that asks gets the wrong answer --
|
||||
`pageSave` POSTs a new page instead of patching the one on screen, the header offers Create
|
||||
Page where Save Changes belongs, and Discard throws away a property edit as though it were an
|
||||
abandoned draft, putting the welcome screen over a wiki that has a home page.
|
||||
*/
|
||||
if (editorStore.isActive) {
|
||||
editorStore.closeEditor()
|
||||
}
|
||||
|
||||
// -> Load Page. The contents panel belongs to the page being left, so it goes with it
|
||||
state.tocPanelOpen = false
|
||||
try {
|
||||
await pageStore.pageLoad({ path: pagePath, locale: pageLocale, applyWith: swapTo })
|
||||
if (editorStore.isActive) {
|
||||
/*
|
||||
Walking away from the editor closes it, and `mode` describes the editor that was open — so
|
||||
it has to go back with it. Left on `create`, it goes on claiming a page is being written
|
||||
long after the reader has moved on to reading one, and everything that asks gets the wrong
|
||||
answer: `pageSave` POSTs a new page instead of patching the one on screen, the header
|
||||
offers Create Page where Save Changes belongs, and Discard throws away a property edit as
|
||||
though it were an abandoned draft — putting the welcome screen over a wiki that has a home
|
||||
page.
|
||||
*/
|
||||
editorStore.$patch({
|
||||
isActive: false,
|
||||
mode: 'edit'
|
||||
})
|
||||
}
|
||||
// -> Load Blocks. `?.` because a locked page draws its lock screen in place of the article, so
|
||||
// there is no content element to scan -- and nothing in it to scan for.
|
||||
nextTick(async () => {
|
||||
|
||||
@@ -93,7 +93,7 @@ function isExternalHref(href) {
|
||||
* a review, a history entry -- resolves from.
|
||||
* @returns {string} The source to render with.
|
||||
*/
|
||||
function fileSrc(src, pagePath = '') {
|
||||
export function fileSrc(src, pagePath = '') {
|
||||
const value = (src ?? '').trim()
|
||||
if (
|
||||
!value ||
|
||||
@@ -206,6 +206,15 @@ function iconShortcode(state, silent) {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker that opens a task list item, as `markdown-it-task-lists` reads it.
|
||||
*
|
||||
* The same three spellings that plugin's own `startsWithTodoMarkdown` accepts, anchored for the same
|
||||
* reason it uses `indexOf(...) === 0`: a marker is only a marker at the start of the item's content.
|
||||
* The trailing space is part of it, which is what keeps a real `[x]{.cls}` span out of this.
|
||||
*/
|
||||
const TASK_LIST_MARKER = /^\[[ xX]\] /
|
||||
|
||||
/**
|
||||
* Everything a fence may say about itself beyond its language.
|
||||
*
|
||||
@@ -367,6 +376,32 @@ function codeBlock(str, lang, attributes) {
|
||||
return `<pre class="codeblock hljs${numbered ? ' line-numbers' : ''}"${numbering}><code class="language-${escape(lang)}">${highlighted.value}${rows}</code></pre>`
|
||||
}
|
||||
|
||||
/**
|
||||
* An emoji as the markup a page shows for it: a twemoji SVG served by this instance.
|
||||
*
|
||||
* Exported because the Visual editor draws emoji too, and a character rendered as text is whatever
|
||||
* font the author's machine happens to have — which is not what the page will show, and on a machine
|
||||
* with no emoji font is not anything at all. Going through this means the editor and the page cannot
|
||||
* disagree about it.
|
||||
*
|
||||
* Drawn from this instance, never from a CDN: the callback replaces twemoji's default `base` + size +
|
||||
* extension entirely, so the `src` is the whole path and nothing upstream is contacted for it.
|
||||
* `vite.config.js` puts the SVGs at `/_assets/svg/twemoji/` — copied into the build output, served out
|
||||
* of `node_modules` in dev — so the two have to agree on this path.
|
||||
*
|
||||
* The artwork comes from the same upstream project as this parser, at a pinned tag (see
|
||||
* `twemoji-assets` in `package.json`). They are separate dependencies, so the build checks that every
|
||||
* emoji a page can hold still resolves to a file — an emoji the parser knows and the asset set does
|
||||
* not is a broken image in a page.
|
||||
*/
|
||||
export function twemojiHtml(char) {
|
||||
return twemoji.parse(char, {
|
||||
callback(icon) {
|
||||
return `/_assets/svg/twemoji/${icon}.svg`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export class MarkdownRenderer {
|
||||
constructor(config = {}) {
|
||||
this.md = new MarkdownIt({
|
||||
@@ -422,10 +457,24 @@ export class MarkdownRenderer {
|
||||
plugins are added in: the span rule is registered before `link`, the footnote rule after
|
||||
`image`, so the span always gets there first.
|
||||
|
||||
Wrapped rather than turned off, because the span is worth keeping and the two are only ever
|
||||
confusable at `[^` — which is a footnote reference and nothing else. Reaching into `__rules__`
|
||||
is the only way to get hold of the original: markdown-it can replace a rule by name but has no
|
||||
way to read one back out.
|
||||
The `[x]` of a task list item is the same collision with a nastier ending, because the task list
|
||||
plugin half-succeeds. It runs as a core rule after the inline is parsed, and decides an item is a
|
||||
task by reading the inline's raw `content` — which still says `[x] done`, so the checkbox is
|
||||
added. Removing the marker is the part that fails: `todoify` slices three characters off
|
||||
`children[1]`, expecting the text token the marker was in, and by then that token is the SPAN
|
||||
MDC opened, whose content is the empty string. Slicing nothing off nothing leaves the marker
|
||||
where it was, and every task item on every wiki rendered as a checkbox followed by a stray
|
||||
`<span>x</span>`.
|
||||
|
||||
Wrapped rather than turned off, because the span is worth keeping and these are the only two
|
||||
readings of `[` it gets wrong. Both guards are as narrow as the thing they protect: `[^` is a
|
||||
footnote reference and nothing else, and a marker is the three characters `[ ]`, `[x]` or `[X]`
|
||||
followed by a space, at the very start of the inline — which is the only place the task list
|
||||
plugin looks for one. The trailing space is what keeps `[x]{.cls}` a span, since a marker cannot
|
||||
be followed by a brace.
|
||||
|
||||
Reaching into `__rules__` is the only way to get hold of the original: markdown-it can replace a
|
||||
rule by name but has no way to read one back out.
|
||||
*/
|
||||
const spanRule = this.md.inline.ruler.__rules__.find((rule) => rule.name === 'mdc_inline_span')
|
||||
const inlineSpan = spanRule.fn
|
||||
@@ -433,6 +482,9 @@ export class MarkdownRenderer {
|
||||
if (state.src[state.pos] === '[' && state.src[state.pos + 1] === '^') {
|
||||
return false
|
||||
}
|
||||
if (state.pos === 0 && TASK_LIST_MARKER.test(state.src)) {
|
||||
return false
|
||||
}
|
||||
return inlineSpan(state, silent)
|
||||
})
|
||||
|
||||
@@ -564,24 +616,8 @@ export class MarkdownRenderer {
|
||||
// TWEMOJI
|
||||
// --------------------------------
|
||||
|
||||
/*
|
||||
Drawn from this instance, never from a CDN: the callback replaces twemoji's default `base` +
|
||||
size + extension entirely, so the `src` is the whole path and nothing upstream is contacted for
|
||||
it. `vite.config.js` puts the SVGs at `/_assets/svg/twemoji/` — copied into the build output,
|
||||
served out of `node_modules` in dev — so the two have to agree on this path.
|
||||
|
||||
The artwork comes from the same upstream project as this parser, at a pinned tag (see
|
||||
`twemoji-assets` in `package.json`). They are separate dependencies, so the build checks that
|
||||
every emoji a page can hold still resolves to a file — an emoji the parser knows and the asset
|
||||
set does not is a broken image in a page.
|
||||
*/
|
||||
this.md.renderer.rules.emoji = (token, idx) => {
|
||||
return twemoji.parse(token[idx].content, {
|
||||
callback(icon, opts) {
|
||||
return `/_assets/svg/twemoji/${icon}.svg`
|
||||
}
|
||||
})
|
||||
}
|
||||
// -> See `twemojiHtml`, which the Visual editor draws its emoji through as well
|
||||
this.md.renderer.rules.emoji = (token, idx) => twemojiHtml(token[idx].content)
|
||||
|
||||
// --------------------------------
|
||||
// Inject line numbers for preview scroll sync
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* The labels are English, as the marker itself is: what the renderer emits is stored as the page's
|
||||
* HTML, so nothing here can follow the reader's locale afterwards.
|
||||
*/
|
||||
const KINDS = new Map([
|
||||
export const KINDS = new Map([
|
||||
['note', { className: 'is-info', label: 'Note' }],
|
||||
['tip', { className: 'is-success', label: 'Tip' }],
|
||||
['important', { className: 'is-important', label: 'Important' }],
|
||||
@@ -32,7 +32,7 @@ const KINDS = new Map([
|
||||
* Left off, the kind's own label stands in, exactly as before. What is captured is raw markdown and is
|
||||
* parsed as such (see `titleTokens`), so a title may hold a link or a `code` span like any other line.
|
||||
*/
|
||||
const MARKER = /^\[!([a-z]+)\][ \t]*([^\n]*)(?:\n|$)/i
|
||||
export const MARKER = /^\[!([a-z]+)\][ \t]*([^\n]*)(?:\n|$)/i
|
||||
|
||||
/**
|
||||
* The title, as three tokens: a paragraph carrying a class, its inline content, and the close.
|
||||
|
||||
@@ -6,15 +6,34 @@ const routes = [
|
||||
component: () => import('@/layouts/AuthLayout.vue'),
|
||||
children: [{ path: '', component: () => import('@/pages/Login.vue') }]
|
||||
},
|
||||
/*
|
||||
The two short links to a page, which resolve and send the reader on rather than drawing anything:
|
||||
`/a/<alias>` names it by the alias somebody chose for it, `/i/<id>` by the id it was born with.
|
||||
|
||||
Neither is a page path — see `RESERVED_ROOT` in `helpers/pagePaths.js`, and the matching set on the
|
||||
server — so neither is locale-prefixed on the way in. What they answer with IS prefixed, because
|
||||
the page they point at may be in any locale and the path alone does not say which.
|
||||
*/
|
||||
{
|
||||
path: '/a/:alias',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
beforeEnter: async (to, from) => {
|
||||
beforeEnter: async (to) => {
|
||||
const pageStore = usePageStore()
|
||||
try {
|
||||
const pathPath = await pageStore.pageAlias(to.params.alias)
|
||||
return `/${pathPath}`
|
||||
} catch (err) {
|
||||
return await pageStore.pageAlias(to.params.alias)
|
||||
} catch {
|
||||
return '/_error/notfound'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/i/:pageId',
|
||||
component: () => import('@/layouts/MainLayout.vue'),
|
||||
beforeEnter: async (to) => {
|
||||
const pageStore = usePageStore()
|
||||
try {
|
||||
return await pageStore.pageById(to.params.pageId)
|
||||
} catch {
|
||||
return '/_error/notfound'
|
||||
}
|
||||
}
|
||||
@@ -145,8 +164,16 @@ const routes = [
|
||||
// --------------------------------
|
||||
// EDIT
|
||||
// --------------------------------
|
||||
/*
|
||||
Editing a page has a path of its own, and that is what makes the editor a screen rather than a
|
||||
mode: opening it is a navigation, and so is every way out of it -- the site logo, a link in the
|
||||
sidebar, the back button. It is also what lets an editor URL be reloaded and linked.
|
||||
|
||||
`(.*)` because a page path is not one segment: `notes/api/errors` is an ordinary page, and a bare
|
||||
`:pagePath` would match only the first segment of it and drop the rest into no route at all.
|
||||
*/
|
||||
{
|
||||
path: '/_edit/:pagePath?',
|
||||
path: '/_edit/:pagePath(.*)?',
|
||||
component: () => import('../layouts/MainLayout.vue'),
|
||||
children: [{ path: '', component: () => import('../pages/Index.vue') }]
|
||||
},
|
||||
|
||||
@@ -42,7 +42,29 @@ export const useEditorStore = defineStore('editor', {
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
addPendingAsset (data) {
|
||||
/**
|
||||
* The editor is no longer open.
|
||||
*
|
||||
* One place rather than a `$patch` at each exit, because "closed" is four fields and every site
|
||||
* that wrote its own got a different subset of them -- leaving a mode of `create` behind after a
|
||||
* new page was abandoned, or an `originPageId` naming the page somebody was reading when they
|
||||
* started writing one, weeks of navigation ago. Both are read by the next editing session and
|
||||
* neither is obviously stale when it is.
|
||||
*
|
||||
* `mode` goes back to `edit` rather than to nothing: it describes the editor that is open and
|
||||
* there is none, so the value that matters is what the NEXT one inherits -- and an editor opened
|
||||
* on a page that exists is an edit.
|
||||
*/
|
||||
closeEditor() {
|
||||
this.$patch({
|
||||
isActive: false,
|
||||
editor: '',
|
||||
mode: 'edit',
|
||||
// -> Only ever meaningful while a page is being created: it is where Discard goes back to
|
||||
originPageId: ''
|
||||
})
|
||||
},
|
||||
addPendingAsset(data) {
|
||||
const blobUrl = URL.createObjectURL(data)
|
||||
if (data instanceof File) {
|
||||
this.pendingAssets.push({
|
||||
@@ -81,13 +103,13 @@ export const useEditorStore = defineStore('editor', {
|
||||
* Revokes the URLs on the way out, since the browser holds the bytes behind each one until it is
|
||||
* told it can let go.
|
||||
*/
|
||||
clearPendingAssets () {
|
||||
clearPendingAssets() {
|
||||
for (const item of this.pendingAssets) {
|
||||
URL.revokeObjectURL(item.blobUrl)
|
||||
}
|
||||
this.pendingAssets = []
|
||||
},
|
||||
async fetchConfigs () {
|
||||
async fetchConfigs() {
|
||||
const siteStore = useSiteStore()
|
||||
try {
|
||||
if (!siteStore.id) {
|
||||
@@ -99,8 +121,7 @@ export const useEditorStore = defineStore('editor', {
|
||||
this.$patch({
|
||||
editors: {
|
||||
asciidoc: siteInfo?.editors?.asciidoc?.config ?? {},
|
||||
markdown: siteInfo?.editors?.markdown?.config ?? {},
|
||||
wysiwyg: siteInfo?.editors?.wysiwyg?.config ?? {}
|
||||
markdown: siteInfo?.editors?.markdown?.config ?? {}
|
||||
},
|
||||
configIsLoaded: true
|
||||
})
|
||||
|
||||
+132
-8
@@ -64,6 +64,13 @@ export const usePageStore = defineStore('page', {
|
||||
* `pageSave` must not write the second one over a page that has content. See the guard there.
|
||||
*/
|
||||
contentLoaded: false,
|
||||
/**
|
||||
* The other editors this page could be opened with, as the server works it out — every editor
|
||||
* producing the same content type, which is Markdown and Visual for a markdown page and nobody at
|
||||
* all for a redirection. Not filtered by what the site has enabled; `PageConvertDialog` is what
|
||||
* narrows it to the editors somebody could actually open afterwards.
|
||||
*/
|
||||
convertibleTo: [],
|
||||
createdAt: '',
|
||||
description: '',
|
||||
editor: '',
|
||||
@@ -80,6 +87,16 @@ export const usePageStore = defineStore('page', {
|
||||
locale: 'en',
|
||||
navigationId: null,
|
||||
navigationMode: 'inherit',
|
||||
/**
|
||||
* The header fields as the server last told us they are, kept beside the live ones.
|
||||
*
|
||||
* Not a duplicate: `title`, `description` and `icon` hold what is ON SCREEN, which the properties
|
||||
* panel edits long before anything is saved. This is what those were before the author touched
|
||||
* them, and the only thing that asks is the collaborative session — see `adoptProps` in
|
||||
* `composables/collab.js`, which needs to tell a room echoing the database back from a room
|
||||
* holding somebody else's unsaved edit.
|
||||
*/
|
||||
storedProps: { title: '', description: '', icon: '' },
|
||||
/**
|
||||
* Whether the path in the URL has no page at all. Set by `pageNotFound`, which empties everything
|
||||
* else here at the same time — so this being true means the store holds the *absence* of a page,
|
||||
@@ -160,6 +177,28 @@ export const usePageStore = defineStore('page', {
|
||||
* this page sends people is the one person who does not want to be sent there. `?redirect=no` is
|
||||
* what holds it — see `PageRedirect.vue` — and the screen it lands on offers to follow it.
|
||||
*/
|
||||
/**
|
||||
* Where the editor for this page lives.
|
||||
*
|
||||
* A path of its own rather than a flag set on the page's own URL. That is the whole point: with
|
||||
* the editor at the page's address, opening it changed nothing the router could see, so leaving it
|
||||
* was left to a watcher on the path — and every way out that did not change the path (the site
|
||||
* logo on the home page, a link to the page being edited) simply did not close it. Here, opening
|
||||
* and closing are both navigations, and `editorExitPath` is the way back.
|
||||
*
|
||||
* The locale rides along as a query rather than as a prefix: `/_edit/...` is the application's own
|
||||
* path and is never bracketed by locale, so the page it names would otherwise be ambiguous on a
|
||||
* site holding the same path in several. Absent for the primary locale, which is what the API
|
||||
* assumes when it is not told.
|
||||
*/
|
||||
editPath: (state) => {
|
||||
const siteStore = useSiteStore()
|
||||
const query =
|
||||
state.locale && state.locale !== siteStore.locales.primary
|
||||
? `?locale=${encodeURIComponent(state.locale)}`
|
||||
: ''
|
||||
return `/_edit/${state.path}${query}`
|
||||
},
|
||||
editorExitPath: (state) => {
|
||||
// -> Prefixed, on a site that brackets its URLs by locale: an unprefixed path is sent to the
|
||||
// PRIMARY locale, so leaving the editor on a page just written in another one landed the
|
||||
@@ -220,7 +259,8 @@ export const usePageStore = defineStore('page', {
|
||||
needs no such help: every page answers with it, so `...pageData` above has already
|
||||
settled whether THIS page is protected.
|
||||
*/
|
||||
notFound: false
|
||||
notFound: false,
|
||||
storedProps: storedPropsOf(pageData)
|
||||
})
|
||||
this.applyViewerState(pageData.viewer)
|
||||
// Update editor state timestamps
|
||||
@@ -276,7 +316,8 @@ export const usePageStore = defineStore('page', {
|
||||
localeRelations: (pageData.localeRelations ?? []).map((r) =>
|
||||
pick(r, ['locale', 'path', 'title'])
|
||||
),
|
||||
tocDepth: pick(pageData.tocDepth, ['min', 'max'])
|
||||
tocDepth: pick(pageData.tocDepth, ['min', 'max']),
|
||||
storedProps: storedPropsOf(pageData)
|
||||
})
|
||||
},
|
||||
/**
|
||||
@@ -382,13 +423,39 @@ export const usePageStore = defineStore('page', {
|
||||
* PAGE - GET PATH FROM ALIAS
|
||||
*/
|
||||
async pageAlias(alias) {
|
||||
return this.resolveShortLink(`alias/${encodeURIComponent(alias)}`)
|
||||
},
|
||||
/**
|
||||
* PAGE - RESOLVE ID
|
||||
*
|
||||
* The same short link by the one name a page never loses. An alias can be retyped and a path can
|
||||
* be moved; the id outlives both, which is what makes `/i/<id>` the link to paste where it has to
|
||||
* keep working.
|
||||
*/
|
||||
async pageById(id) {
|
||||
return this.resolveShortLink(`id/${encodeURIComponent(id)}`)
|
||||
},
|
||||
/**
|
||||
* Where a short link points, as a path this app can navigate to.
|
||||
*
|
||||
* The locale comes back with the path and is part of the answer, not decoration: on a site that
|
||||
* brackets its URLs by locale, `/notes/one` is the ENGLISH page and the French one is at
|
||||
* `/fr/notes/one`. Built with `localeUrlPrefix`, which is empty where the site needs no prefix —
|
||||
* so a short link to a page in another locale lands on that page rather than on whatever happens
|
||||
* to sit at the same path in the primary one.
|
||||
*
|
||||
* @param {string} lookup The `alias/<alias>` or `id/<uuid>` half of the endpoint path.
|
||||
* @returns {Promise<string>} A rooted path, prefix and all.
|
||||
* @throws `ERR_PAGE_NOT_FOUND` when nothing answers to it, or the reader may not know it does.
|
||||
*/
|
||||
async resolveShortLink(lookup) {
|
||||
const siteStore = useSiteStore()
|
||||
try {
|
||||
const pagePath = await API_CLIENT.get(`sites/${siteStore.id}/pages/alias/${alias}`).json()
|
||||
if (!pagePath?.id) {
|
||||
const target = await API_CLIENT.get(`sites/${siteStore.id}/pages/${lookup}`).json()
|
||||
if (!target?.id) {
|
||||
throw new Error('ERR_PAGE_NOT_FOUND')
|
||||
}
|
||||
return pagePath.path
|
||||
return `${siteStore.localeUrlPrefix(target.locale)}/${target.path}`
|
||||
} catch (err) {
|
||||
if (err.response?.status === 404) {
|
||||
throw new Error('ERR_PAGE_NOT_FOUND')
|
||||
@@ -526,6 +593,8 @@ export const usePageStore = defineStore('page', {
|
||||
isSearchable: props.isSearchable ?? editor !== 'redirect',
|
||||
// -> The page being created is very often the one that was missing, and it is not missing now
|
||||
notFound: false,
|
||||
// -> Nothing is stored for a page that does not exist, so everything about it is pending
|
||||
storedProps: { title: '', description: '', icon: '' },
|
||||
mode: 'edit'
|
||||
})
|
||||
},
|
||||
@@ -671,8 +740,21 @@ export const usePageStore = defineStore('page', {
|
||||
load would replace every field with what is stored and reset the change timestamps, throwing
|
||||
those edits away without a word. The source is the only thing missing in that state, so the
|
||||
source is the only thing fetched.
|
||||
|
||||
`isPageInStore` is what keeps that from being a way to open the WRONG page. Pending changes
|
||||
belong to whatever this store is holding, and the flag says nothing about which page that is —
|
||||
so opening the editor on a different one took the shortcut too, skipped the load, and left the
|
||||
author editing the previous page's title, description and icon under the new page's name.
|
||||
Worse where there was no previous page to speak of: a create that was abandoned leaves the
|
||||
store holding a blank one, and every Edit after it opened on empty metadata.
|
||||
|
||||
A page identified by neither an id nor a path is this store's own page by definition, which is
|
||||
what the header's Edit button asks for.
|
||||
*/
|
||||
if (editorStore.hasPendingChanges) {
|
||||
const isPageInStore = id
|
||||
? id === this.id
|
||||
: !path || normalizePath(path) === normalizePath(this.path)
|
||||
if (editorStore.hasPendingChanges && isPageInStore) {
|
||||
await this.pageLoadSource()
|
||||
} else {
|
||||
await this.pageLoad(loadArgs)
|
||||
@@ -857,7 +939,9 @@ export const usePageStore = defineStore('page', {
|
||||
localeRelations: (pageData.localeRelations ?? []).map((r) =>
|
||||
pick(r, ['locale', 'path', 'title'])
|
||||
),
|
||||
tocDepth: pick(pageData.tocDepth, ['min', 'max'])
|
||||
tocDepth: pick(pageData.tocDepth, ['min', 'max']),
|
||||
// -> What was pending is now what is stored, which is the whole of what a save means here
|
||||
storedProps: storedPropsOf(pageData)
|
||||
})
|
||||
|
||||
/*
|
||||
@@ -893,16 +977,56 @@ export const usePageStore = defineStore('page', {
|
||||
throw err
|
||||
}
|
||||
},
|
||||
/**
|
||||
* Out of the editor: the URL first, then the state.
|
||||
*
|
||||
* Editing has a path of its own (see `editPath`), so closing the editor without leaving that path
|
||||
* would strand the author on an editor URL with no editor on it. The order is deliberate and the
|
||||
* navigation is awaited: a page view drawn at the editor's route reads the query as it stands, and
|
||||
* for a redirection that means following it out from under whoever has just finished writing it.
|
||||
*
|
||||
* Where there is no editor path to leave -- a properties-only edit, which never opened one, or a
|
||||
* suggestion, which is written at the page's own address -- nothing navigates and only the state
|
||||
* changes.
|
||||
*/
|
||||
async leaveEditor() {
|
||||
const editorStore = useEditorStore()
|
||||
if (this.router.currentRoute.value.path.startsWith('/_edit')) {
|
||||
await this.router.replace(this.editorExitPath)
|
||||
}
|
||||
editorStore.closeEditor()
|
||||
},
|
||||
/**
|
||||
* Throw away what is unsaved and put the stored page back.
|
||||
*
|
||||
* Leaving the editor's own path is what reloads the page — the page view's route watcher does it —
|
||||
* so the load here is for the case where there is no such path to leave: a property edit discarded
|
||||
* without the editor ever having been opened, where the route does not change and nothing else
|
||||
* would put the page back.
|
||||
*/
|
||||
async cancelPageEdit() {
|
||||
const editorStore = useEditorStore()
|
||||
if (this.router.currentRoute.value.path.startsWith('/_edit')) {
|
||||
// -> Awaited for the same reason as in `pageSave`: the editor closes when this resolves
|
||||
await this.router.replace(this.editorExitPath)
|
||||
return
|
||||
}
|
||||
await this.pageLoad({ id: editorStore.originPageId ? editorStore.originPageId : this.id })
|
||||
// -> Awaited for the same reason as in `pageSave`: the editor closes when this resolves
|
||||
await this.router.replace(this.editorExitPath)
|
||||
},
|
||||
generateToc() {}
|
||||
}
|
||||
})
|
||||
|
||||
/** The three header fields a collaborative session compares against, as the server just stated them. */
|
||||
function storedPropsOf(pageData) {
|
||||
return {
|
||||
title: pageData.title ?? '',
|
||||
description: pageData.description ?? '',
|
||||
icon: pageData.icon ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a refused request back into an error.
|
||||
*
|
||||
|
||||
@@ -132,7 +132,7 @@ export const useSiteStore = defineStore('site', {
|
||||
editors: {
|
||||
asciidoc: false,
|
||||
markdown: false,
|
||||
wysiwyg: false
|
||||
visual: false
|
||||
},
|
||||
/** Every installed locale, as this wiki refers to it. Empty until the app has bootstrapped. */
|
||||
installedLocales: [],
|
||||
@@ -257,10 +257,13 @@ export const useSiteStore = defineStore('site', {
|
||||
* Three questions at once, and all three have to be asked or the list is fiction: whether the
|
||||
* site has the editor turned on (`editors`, the admin area's Editors screen), whether it is
|
||||
* implemented at all — `channel`, `blog` and `api` are names with no editor behind them yet, and
|
||||
* `wysiwyg` and `asciidoc` are half-built, so all five are behind the experimental flag — and
|
||||
* `redirect`, which no site can turn off because it authors nothing: a redirection is a page with
|
||||
* a target instead of a body. On a wiki with the flag off that leaves Markdown and Redirection,
|
||||
* which is what most of them run.
|
||||
* `asciidoc` is half-built, so all four are behind the experimental flag — and `redirect`, which
|
||||
* no site can turn off because it authors nothing: a redirection is a page with a target instead
|
||||
* of a body. On a wiki with the flag off that leaves Markdown, Visual and Redirection, in that
|
||||
* order: Markdown is what most pages are written with, so it is the one offered first.
|
||||
*
|
||||
* Markdown and Visual are two views of the same markdown source, which is what lets a page move
|
||||
* between them — see `interchangeableEditors` on the server.
|
||||
*
|
||||
* `PageNewMenu` draws its items from this, so what a page can be created with and what a search
|
||||
* can be filtered by cannot drift apart.
|
||||
@@ -269,8 +272,8 @@ export const useSiteStore = defineStore('site', {
|
||||
const flagsStore = useFlagsStore()
|
||||
const experimental = flagsStore.experimental
|
||||
return [
|
||||
...(experimental && this.editors.wysiwyg ? ['wysiwyg'] : []),
|
||||
...(this.editors.markdown ? ['markdown'] : []),
|
||||
...(this.editors.visual ? ['visual'] : []),
|
||||
...(experimental && this.editors.asciidoc ? ['asciidoc'] : []),
|
||||
...(experimental ? ['channel', 'blog', 'api'] : []),
|
||||
'redirect'
|
||||
@@ -388,7 +391,7 @@ export const useSiteStore = defineStore('site', {
|
||||
editors: {
|
||||
asciidoc: siteInfo.editors.asciidoc.isActive,
|
||||
markdown: siteInfo.editors.markdown.isActive,
|
||||
wysiwyg: siteInfo.editors.wysiwyg.isActive
|
||||
visual: siteInfo.editors.visual.isActive
|
||||
},
|
||||
// -> Spread over the state defaults, as `features` and `theme` above do, so a key the
|
||||
// site config has never been saved with reads as its default rather than undefined
|
||||
|
||||
Reference in new issue
Block a user