diff --git a/backend/locales/en.json b/backend/locales/en.json index a191912b9..a681a2e81 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -192,8 +192,8 @@ "admin.audit.actions.registerPasskey": "Registered a passkey", "admin.audit.actions.rejectPageEdit": "Declined an edit suggestion", "admin.audit.actions.renderPage": "Queued a page for rendering", - "admin.audit.actions.rerenderPages": "Re-rendered every page", "admin.audit.actions.requestPasswordReset": "Requested a password reset", + "admin.audit.actions.rerenderPages": "Re-rendered every page", "admin.audit.actions.resetPassword": "Reset a password from an emailed link", "admin.audit.actions.resetUserPassword": "Set a user's password", "admin.audit.actions.resizeAsset": "Resized an image", @@ -492,7 +492,7 @@ "admin.general.allowBrowse": "Allow Browsing", "admin.general.allowBrowseHint": "Can users browse using the tree structure of the site to pages they have access to?", "admin.general.allowCollaborativeEditing": "Allow Collaborative Editing", - "admin.general.allowCollaborativeEditingHint": "Can several people edit the same page at the same time, seeing each other's cursors and changes live? Applies to the markdown editor. Changes are still only stored when someone saves the page.", + "admin.general.allowCollaborativeEditingHint": "Can several people edit the same page at the same time, seeing each other's cursors and changes live? Changes are still only stored when someone saves the page.", "admin.general.allowComments": "Allow Comments", "admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.", "admin.general.allowLastEditedBy": "Allow Last Edited By", @@ -1526,74 +1526,74 @@ "admin.utilities.invalidSessionSecretConfirmWarn": "Everyone is logged out immediately, you included — you will have to sign in again. The new secret is only used for signing once each server has been restarted. API keys are unaffected.", "admin.utilities.invalidSessionSecretFailed": "Failed to rotate the user sessions secret.", "admin.utilities.invalidSessionSecretHint": "Rotate the secret used to sign session cookies and end every open session. Everyone is logged out.", - "admin.utilities.pageProblems.checksTitle": "Checklist", - "admin.utilities.pageProblems.logClean": "Scan complete: no problems found.", - "admin.utilities.pageProblems.logFinished": "Scan complete: {errors} error(s) and {warnings} warning(s) found.", - "admin.utilities.pageProblems.logStarted": "Scan started: {pages} page(s) to check, on every site.", - "admin.utilities.pageProblems.logStopped": "Scan stopped after {scanned} of {total}.", - "admin.utilities.pageProblems.noProblems": "No problems found.", - "admin.utilities.pageProblems.progress": "Progress", - "admin.utilities.pageProblems.progressEmpty": "Start a scan to check every page on every site. Nothing is changed: problems are only reported.", - "admin.utilities.pageProblems.start": "Start Scan", - "admin.utilities.pageProblems.stop": "Stop Scan", - "admin.utilities.pageProblems.subtitle": "Check every page on every site for broken or out-of-step data", - "admin.utilities.pageProblems.groups.content": "Content", - "admin.utilities.pageProblems.groups.editor": "Editor", - "admin.utilities.pageProblems.groups.redirects": "Redirections", - "admin.utilities.pageProblems.groups.tree": "File Tree", - "admin.utilities.pageProblems.groups.address": "Address", - "admin.utilities.pageProblems.groups.locales": "Locales", - "admin.utilities.pageProblems.groups.publishing": "Publishing", - "admin.utilities.pageProblems.groups.search": "Search", - "admin.utilities.pageProblems.groups.navigation": "Navigation", + "admin.utilities.pageProblems.checks.aliasDuplicate": "Alias used by more than one page", "admin.utilities.pageProblems.checks.contentEmpty": "Empty content", - "admin.utilities.pageProblems.checks.renderEmpty": "Empty render", - "admin.utilities.pageProblems.checks.renderPending": "Render still pending after an import", - "admin.utilities.pageProblems.checks.contentInvalidJson": "Unreadable settings on a redirection or blog", "admin.utilities.pageProblems.checks.contentInvalid": "Settings a save would refuse", - "admin.utilities.pageProblems.checks.editorUnknown": "Unknown editor", + "admin.utilities.pageProblems.checks.contentInvalidJson": "Unreadable settings on a redirection or blog", "admin.utilities.pageProblems.checks.editorDisabled": "Editor turned off for the site", - "admin.utilities.pageProblems.checks.redirectTargetMissing": "Target page does not exist", - "admin.utilities.pageProblems.checks.redirectSelf": "Redirects to itself", - "admin.utilities.pageProblems.checks.redirectLoop": "Redirection loop", - "admin.utilities.pageProblems.checks.redirectChainTooLong": "Chain too long for readers to follow", - "admin.utilities.pageProblems.checks.redirectChain": "Redirects to another redirection", - "admin.utilities.pageProblems.checks.treeEntryMissing": "Page missing from the file tree", - "admin.utilities.pageProblems.checks.treeEntryMismatch": "File tree entry in the wrong place", - "admin.utilities.pageProblems.checks.treeEntryOrphaned": "File tree entry with no page", - "admin.utilities.pageProblems.checks.treeEntryStale": "File tree entry out of date", + "admin.utilities.pageProblems.checks.editorUnknown": "Unknown editor", "admin.utilities.pageProblems.checks.hashMismatch": "Path hash does not match the path", - "admin.utilities.pageProblems.checks.aliasDuplicate": "Alias used by more than one page", - "admin.utilities.pageProblems.checks.localeInactive": "Locale not active on the site", "admin.utilities.pageProblems.checks.localeGroupOrphaned": "Set of translations with a single page", - "admin.utilities.pageProblems.checks.scheduledWithoutDates": "Scheduled with no dates", + "admin.utilities.pageProblems.checks.localeInactive": "Locale not active on the site", + "admin.utilities.pageProblems.checks.navigationMenuMissing": "Sidebar menu missing", "admin.utilities.pageProblems.checks.publishDatesInverted": "Publishing window ends before it starts", + "admin.utilities.pageProblems.checks.redirectChain": "Redirects to another redirection", + "admin.utilities.pageProblems.checks.redirectChainTooLong": "Chain too long for readers to follow", + "admin.utilities.pageProblems.checks.redirectLoop": "Redirection loop", + "admin.utilities.pageProblems.checks.redirectSelf": "Redirects to itself", + "admin.utilities.pageProblems.checks.redirectTargetMissing": "Target page does not exist", + "admin.utilities.pageProblems.checks.renderEmpty": "Empty render", + "admin.utilities.pageProblems.checks.renderPending": "Render still pending after an import", + "admin.utilities.pageProblems.checks.scheduledWithoutDates": "Scheduled with no dates", "admin.utilities.pageProblems.checks.searchIndexMissing": "Missing from the search index", - "admin.utilities.pageProblems.checks.navigationMenuMissing": "Sidebar menu missing", + "admin.utilities.pageProblems.checks.treeEntryMismatch": "File tree entry in the wrong place", + "admin.utilities.pageProblems.checks.treeEntryMissing": "Page missing from the file tree", + "admin.utilities.pageProblems.checks.treeEntryOrphaned": "File tree entry with no page", + "admin.utilities.pageProblems.checks.treeEntryStale": "File tree entry out of date", + "admin.utilities.pageProblems.checksTitle": "Checklist", + "admin.utilities.pageProblems.groups.address": "Address", + "admin.utilities.pageProblems.groups.content": "Content", + "admin.utilities.pageProblems.groups.editor": "Editor", + "admin.utilities.pageProblems.groups.locales": "Locales", + "admin.utilities.pageProblems.groups.navigation": "Navigation", + "admin.utilities.pageProblems.groups.publishing": "Publishing", + "admin.utilities.pageProblems.groups.redirects": "Redirections", + "admin.utilities.pageProblems.groups.search": "Search", + "admin.utilities.pageProblems.groups.tree": "File Tree", + "admin.utilities.pageProblems.logClean": "Scan complete: no problems found.", + "admin.utilities.pageProblems.logFinished": "Scan complete: {errors} error(s) and {warnings} warning(s) found.", + "admin.utilities.pageProblems.logStarted": "Scan started: {pages} page(s) to check, on every site.", + "admin.utilities.pageProblems.logStopped": "Scan stopped after {scanned} of {total}.", + "admin.utilities.pageProblems.noProblems": "No problems found.", + "admin.utilities.pageProblems.problems.aliasDuplicate": "It shares the alias \"{alias}\" with another page on the same site.", "admin.utilities.pageProblems.problems.contentEmpty": "Its content is empty.", - "admin.utilities.pageProblems.problems.renderEmpty": "Its rendered HTML is empty, so readers see a blank page. Saving it again from the {editor} editor renders it.", - "admin.utilities.pageProblems.problems.renderPending": "It still shows the import placeholder: the page was never rendered after it was imported.", - "admin.utilities.pageProblems.problems.contentInvalidJson": "Its {editor} settings are not a valid JSON object, so the editor opens blank.", "admin.utilities.pageProblems.problems.contentInvalid": "Its settings would be refused if it were saved: {reason}", - "admin.utilities.pageProblems.problems.editorUnknown": "It uses an editor this wiki does not have ({editor}), so nobody can edit it.", + "admin.utilities.pageProblems.problems.contentInvalidJson": "Its {editor} settings are not a valid JSON object, so the editor opens blank.", "admin.utilities.pageProblems.problems.editorDisabled": "It uses the {editor} editor, which is turned off for this site.", - "admin.utilities.pageProblems.problems.redirectTargetMissing": "It redirects to {target}, where there is no page.", - "admin.utilities.pageProblems.problems.redirectSelf": "It redirects to itself.", - "admin.utilities.pageProblems.problems.redirectLoop": "It redirects to {target}, which leads round in a loop.", - "admin.utilities.pageProblems.problems.redirectChainTooLong": "It redirects to {target}, starting a chain of more than {max} redirections. Readers are stopped partway.", - "admin.utilities.pageProblems.problems.redirectChain": "It redirects to {target}, which is itself a redirection: readers pass through {hops} in a row.", - "admin.utilities.pageProblems.problems.treeEntryMissing": "It has no file tree entry, so it is missing from the file browser and the sidebar.", - "admin.utilities.pageProblems.problems.treeEntryMismatch": "Its file tree entry is filed at {treePath} instead.", - "admin.utilities.pageProblems.problems.treeEntryOrphaned": "It is listed in the file tree, but there is no page behind the entry.", - "admin.utilities.pageProblems.problems.treeEntryStale": "Its file tree entry has out-of-date values for: {fields}.", + "admin.utilities.pageProblems.problems.editorUnknown": "It uses an editor this wiki does not have ({editor}), so nobody can edit it.", "admin.utilities.pageProblems.problems.hashMismatch": "Its path hash does not match its path, so it cannot be found at its own address.", - "admin.utilities.pageProblems.problems.aliasDuplicate": "It shares the alias \"{alias}\" with another page on the same site.", - "admin.utilities.pageProblems.problems.localeInactive": "It is in the {locale} locale, which is not active on its site.", "admin.utilities.pageProblems.problems.localeGroupOrphaned": "It belongs to a set of translations with no other page in it.", - "admin.utilities.pageProblems.problems.scheduledWithoutDates": "It is scheduled, but has neither a start nor an end date.", + "admin.utilities.pageProblems.problems.localeInactive": "It is in the {locale} locale, which is not active on its site.", + "admin.utilities.pageProblems.problems.navigationMenuMissing": "Its sidebar points at a menu that does not exist, so it shows no menu (navigation mode: {mode}).", "admin.utilities.pageProblems.problems.publishDatesInverted": "Its publishing window ends before it starts.", + "admin.utilities.pageProblems.problems.redirectChain": "It redirects to {target}, which is itself a redirection: readers pass through {hops} in a row.", + "admin.utilities.pageProblems.problems.redirectChainTooLong": "It redirects to {target}, starting a chain of more than {max} redirections. Readers are stopped partway.", + "admin.utilities.pageProblems.problems.redirectLoop": "It redirects to {target}, which leads round in a loop.", + "admin.utilities.pageProblems.problems.redirectSelf": "It redirects to itself.", + "admin.utilities.pageProblems.problems.redirectTargetMissing": "It redirects to {target}, where there is no page.", + "admin.utilities.pageProblems.problems.renderEmpty": "Its rendered HTML is empty, so readers see a blank page. Saving it again from the {editor} editor renders it.", + "admin.utilities.pageProblems.problems.renderPending": "It still shows the import placeholder: the page was never rendered after it was imported.", + "admin.utilities.pageProblems.problems.scheduledWithoutDates": "It is scheduled, but has neither a start nor an end date.", "admin.utilities.pageProblems.problems.searchIndexMissing": "It is searchable, but missing from the search index. Rebuilding the search index adds it.", - "admin.utilities.pageProblems.problems.navigationMenuMissing": "Its sidebar points at a menu that does not exist, so it shows no menu (navigation mode: {mode}).", + "admin.utilities.pageProblems.problems.treeEntryMismatch": "Its file tree entry is filed at {treePath} instead.", + "admin.utilities.pageProblems.problems.treeEntryMissing": "It has no file tree entry, so it is missing from the file browser and the sidebar.", + "admin.utilities.pageProblems.problems.treeEntryOrphaned": "It is listed in the file tree, but there is no page behind the entry.", + "admin.utilities.pageProblems.problems.treeEntryStale": "Its file tree entry has out-of-date values for: {fields}.", + "admin.utilities.pageProblems.progress": "Progress", + "admin.utilities.pageProblems.progressEmpty": "Start a scan to check every page on every site. Nothing is changed: problems are only reported.", + "admin.utilities.pageProblems.start": "Start Scan", + "admin.utilities.pageProblems.stop": "Stop Scan", + "admin.utilities.pageProblems.subtitle": "Check every page on every site for broken or out-of-step data", "admin.utilities.pageRenders.count": "{processed} / {total}", "admin.utilities.pageRenders.logBatch": "Batch of pages {from}–{to} of {total}", "admin.utilities.pageRenders.logFailed": "failed: {message}", @@ -1680,8 +1680,8 @@ "admin.utilities.wikijs2Import.htmlConversionHtml": "Keep as raw HTML", "admin.utilities.wikijs2Import.htmlConversionMarkdown": "Convert to markdown (recommended)", "admin.utilities.wikijs2Import.inProgress": "Import in progress...", - "admin.utilities.wikijs2Import.navigation": "Navigation", "admin.utilities.wikijs2Import.logStarted": "Reading {file}...", + "admin.utilities.wikijs2Import.navigation": "Navigation", "admin.utilities.wikijs2Import.noArchive": "No archive selected", "admin.utilities.wikijs2Import.options": "Options", "admin.utilities.wikijs2Import.overwrite": "Overwrite on conflict", @@ -2344,6 +2344,22 @@ "editor.markup.headerLevel": "Header {level}", "editor.markup.heading": "Heading {level}", "editor.markup.highlight": "Highlight", + "editor.markup.image.alignCenter": "Center", + "editor.markup.image.alignHint": "Right floats the image, so the text beside it wraps around it.", + "editor.markup.image.alignLeft": "Left", + "editor.markup.image.alignRight": "Right", + "editor.markup.image.alignment": "Alignment", + "editor.markup.image.browse": "Pick from the File Manager", + "editor.markup.image.gone": "The image was changed while the dialog was open, so nothing was applied.", + "editor.markup.image.previewFailed": "The image could not be loaded.", + "editor.markup.image.src": "Image URL", + "editor.markup.image.srcRequired": "An image needs an address.", + "editor.markup.image.styleBorder": "Border", + "editor.markup.image.styleRounded": "Rounded Corners", + "editor.markup.image.styleShadow": "Shadow", + "editor.markup.image.styles": "Styling", + "editor.markup.imageProperties": "Image Properties", + "editor.markup.imagePropertiesNth": "Image Properties ({n})", "editor.markup.inlineCode": "Inline Code", "editor.markup.insertAbbreviation": "Insert Abbreviation", "editor.markup.insertAssets": "Insert Assets", @@ -2558,7 +2574,7 @@ "editor.visual.deflist.termPlaceholder": "The word being defined", "editor.visual.deflist.title": "Definition list", "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.altHint": "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}", @@ -2567,7 +2583,6 @@ "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…", diff --git a/frontend/src/assets/icons.generated.js b/frontend/src/assets/icons.generated.js index 499456b0c..0cf5feb23 100644 --- a/frontend/src/assets/icons.generated.js +++ b/frontend/src/assets/icons.generated.js @@ -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. - 300 icons. + 302 icons. */ export const BUNDLED_ICONS = { "la:angle-down": {"body":"","width":32,"height":32}, @@ -227,6 +227,7 @@ export const BUNDLED_ICONS = { "mdi:file-tree-outline": {"body":"","width":24,"height":24}, "mdi:fit-to-screen-outline": {"body":"","width":24,"height":24}, "mdi:flag-outline": {"body":"","width":24,"height":24}, + "mdi:folder-image": {"body":"","width":24,"height":24}, "mdi:food-apple-outline": {"body":"","width":24,"height":24}, "mdi:format-align-center": {"body":"","width":24,"height":24}, "mdi:format-align-left": {"body":"","width":24,"height":24}, @@ -257,6 +258,7 @@ export const BUNDLED_ICONS = { "mdi:hand-wave-outline": {"body":"","width":24,"height":24}, "mdi:highlight-off": {"body":"","width":24,"height":24}, "mdi:home": {"body":"","width":24,"height":24}, + "mdi:image-outline": {"body":"","width":24,"height":24}, "mdi:image-plus-outline": {"body":"","width":24,"height":24}, "mdi:image-sync-outline": {"body":"","width":24,"height":24}, "mdi:import": {"body":"","width":24,"height":24}, diff --git a/frontend/src/components/EditorAsciidoc.vue b/frontend/src/components/EditorAsciidoc.vue index 59ced8911..577012cf7 100644 --- a/frontend/src/components/EditorAsciidoc.vue +++ b/frontend/src/components/EditorAsciidoc.vue @@ -301,6 +301,7 @@ import { useI18n } from 'vue-i18n' import { bindCollabEditor, startCollabSession, stopCollabSession } from '@/composables/collab' import { dialog } from '@/composables/dialog' +import { useImagePropertiesLens } from '@/composables/imagePropertiesLens' import { notify } from '@/composables/notify' import { useMinWidth } from '@/composables/screen' import { isVisible } from '@/helpers/anchors' @@ -314,6 +315,7 @@ import { findTabsets, writeBlockContent } from '@/helpers/asciidocBlocks' +import { findImages, imageValues, writeImage } from '@/helpers/asciidocImages' import { ASCIIDOC_LANGUAGE_ID, registerAsciidocLanguage } from '@/helpers/monacoAsciidoc' import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue' @@ -433,6 +435,15 @@ const PREVIEW_CONTEXT_ABOVE = 0.2 */ const isAtLeastMd = useMinWidth(1024) +/** The "Image Properties" lens, over every image in the source. See `useImagePropertiesLens`. */ +const imageLens = useImagePropertiesLens({ + getEditor: () => editor, + languageId: ASCIIDOC_LANGUAGE_ID, + findImages, + imageValues, + writeImage +}) + const state = reactive({ // -> Read once, as a DEFAULT rather than a binding: past this the pane is the author's to open and // close, and a bound one would slam it shut the moment a window was dragged narrower mid-edit @@ -466,6 +477,10 @@ function insertAssets() { * is a link to a PDF, not a broken picture. */ function insertAssetClb(opts) { + // -> A pick the Image Properties dialog asked for is the dialog's, not the cursor's + if (imageLens.takePick(opts)) { + return + } let content = '' switch (opts.type) { case 'asset': { @@ -1443,6 +1458,9 @@ onMounted(async () => { } }) + // -> "Image Properties" over every image in the page -- see `useImagePropertiesLens` + imageLens.register() + // -> Define Formatting Actions editor.addAction({ contextMenuGroupId: 'asciidoc.editing', @@ -1634,6 +1652,7 @@ onBeforeUnmount(() => { monacoRef.value?.removeEventListener('drop', onEditorDrop) // -> Registered against the language, not this editor, so nothing else takes it down blockLensProvider?.dispose() + imageLens.dispose() // -> Before the editor goes: the binding is holding the model, and leaving the room is what takes // this author's avatar out of everyone else's header stopCollabSession() diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue index 1cbd8d1cf..aa2ba6fe9 100644 --- a/frontend/src/components/EditorMarkdown.vue +++ b/frontend/src/components/EditorMarkdown.vue @@ -343,6 +343,7 @@ import { useI18n } from 'vue-i18n' import { bindCollabEditor, startCollabSession, stopCollabSession } from '@/composables/collab' import { dialog } from '@/composables/dialog' +import { useImagePropertiesLens } from '@/composables/imagePropertiesLens' import { notify } from '@/composables/notify' import { useMinWidth } from '@/composables/screen' import { isVisible } from '@/helpers/anchors' @@ -355,6 +356,7 @@ import { findBlocks, writeBlockContent } from '@/helpers/markdownBlocks' +import { findImages, imageValues, writeImage } from '@/helpers/markdownImages' import { findEditableTables } from '@/helpers/markdownTable' import { scrollBehavior } from '@/helpers/motion' @@ -491,6 +493,15 @@ const PREVIEW_CONTEXT_ABOVE = 0.2 */ const isAtLeastMd = useMinWidth(1024) +/** The "Image Properties" lens, over every image in the source. See `useImagePropertiesLens`. */ +const imageLens = useImagePropertiesLens({ + getEditor: () => editor, + languageId: 'markdown', + findImages, + imageValues, + writeImage +}) + const state = reactive({ /* Read once, as a DEFAULT rather than a binding: past this first value the pane is the author's to open @@ -517,6 +528,10 @@ function insertAssets() { * file that arrives by drop. */ function insertAssetClb(opts) { + // -> A pick the Image Properties dialog asked for is the dialog's, not the cursor's + if (imageLens.takePick(opts)) { + return + } let content = '' switch (opts.type) { case 'asset': { @@ -1679,6 +1694,9 @@ onMounted(async () => { } }) + // -> "Image Properties" over every image in the page -- see `useImagePropertiesLens` + imageLens.register() + // -> Define Formatting Actions editor.addAction({ contextMenuGroupId: 'markdown.extension.editing', @@ -1895,6 +1913,7 @@ onBeforeUnmount(() => { // -> Registered against the markdown language, not this editor, so nothing else takes it down tableLensProvider?.dispose() blockLensProvider?.dispose() + imageLens.dispose() // -> Before the editor goes: the binding is holding the model, and leaving the room is what takes // this author's avatar out of everyone else's header stopCollabSession() diff --git a/frontend/src/components/EditorVisual.vue b/frontend/src/components/EditorVisual.vue index 026f9a693..7717424e4 100644 --- a/frontend/src/components/EditorVisual.vue +++ b/frontend/src/components/EditorVisual.vue @@ -371,9 +371,11 @@ import { debounce } from 'es-toolkit/function' import { collabHandles, startCollabSession, stopCollabSession } from '@/composables/collab' import { dialog } from '@/composables/dialog' +import { useImagePropertiesDialog } from '@/composables/imagePropertiesDialog' import { notify } from '@/composables/notify' import { assetPath } from '@/helpers/assets' import { blockMarkdown } from '@/helpers/blocks' +import { classValues } from '@/helpers/markdownImages' import EditorCodeBlockMenu from '@/components/EditorCodeBlockMenu.vue' import EditorEmojiMenu from '@/components/EditorEmojiMenu.vue' @@ -408,7 +410,7 @@ import { toggleHeading, toggleTaskList } from '@/editor/visual/commands' -import { applyImageEdit, applyImageSrc, findImage } from '@/editor/visual/images' +import { applyImageEdit, applyImageSrc, findImage, imageClasses } from '@/editor/visual/images' import { applyLink, removeLink } from '@/editor/visual/links' import { ALERT_KINDS, schema } from '@/editor/visual/schema' @@ -732,6 +734,10 @@ watch( /** What the file manager handed back, written the way the Markdown editor writes it. */ function insertAssetClb(opts) { + // -> A pick the Image Properties dialog asked for is the dialog's, not the caret's + if (imageDialog.takePick(opts)) { + return + } const replacing = replacingImage && findImage(editor.view.state) replacingImage = false if (replacing) { @@ -796,29 +802,44 @@ function unlink(range) { } /** - * The selected image's alt text and how big it is drawn — the latter stored as - * `markdown-it-imsize`'s `=WxH`. + * Everything about the selected image, in the same dialog the Markdown and AsciiDoc editors open from + * their Image Properties lens: where it loads from, its alt text, its size, its alignment and its + * framing. * - * The picture's own size comes off the element that is drawing it rather than out of the document, - * since nothing in the source says what it is — it is offered as a hint, so that "half of it" is a - * sum an author can do. + * Alignment and framing are classes, held on the node as `mdAttrs.class` exactly as the `{.class}` + * suffix holds them in the source, so `classValues` / `applyClassValues` from the markdown side are + * what read and write them -- one answer to which classes the dialog owns, whichever editor asked. */ function editImage(target) { - const el = editor.view.nodeDOM(target.pos) - dialog({ - component: defineAsyncComponent(() => import('./ImageEditDialog.vue')), - componentProps: { - alt: target.node.attrs.alt ?? '', - width: target.node.attrs.width ?? '', - height: target.node.attrs.height ?? '', - naturalWidth: el?.naturalWidth ?? 0, - naturalHeight: el?.naturalHeight ?? 0 - } - }).onOk(({ alt, width, height }) => { - applyImageEdit(editor.view, target.pos, { alt, width, height }) + const { attrs } = target.node + imageDialog.open(target, { + src: attrs.src ?? '', + alt: attrs.alt ?? '', + width: attrs.width ?? '', + height: attrs.height ?? '', + ...classValues(imageClasses(target.node)) }) } +/** + * The dialog's answer, onto the image it was opened over. + * + * Found again from the selection rather than from the position the bar handed over: the selection is + * mapped through every transaction while the dialog is up -- a trip to the file manager included, and + * a collaborator's typing -- and a stored position is not. Where the selection is no longer on an + * image, there is nothing to write to. + */ +const imageDialog = useImagePropertiesDialog({ + apply(_target, values) { + const current = findImage(editor.view.state) + if (!current) { + notify({ type: 'warning', message: t('editor.markup.image.gone') }) + return + } + applyImageEdit(editor.view, current.pos, values) + } +}) + /** * A new link, from the page-or-URL picker. * diff --git a/frontend/src/components/FileManager.vue b/frontend/src/components/FileManager.vue index 8ba2106ed..7ab868543 100644 --- a/frontend/src/components/FileManager.vue +++ b/frontend/src/components/FileManager.vue @@ -2385,27 +2385,33 @@ function handleKeyPress(ev) { onMounted(async () => { window.addEventListener('keydown', handleKeyPress) - const pathParts = pageStore.path.split('/') - const parentPath = pathParts.slice(0, -1).join('/') + /* + The folder whoever opened the manager asked for -- the Image Properties dialog asks for the one the + image is in -- and otherwise the folder of the page being viewed. Empty is the site root, which is + an answer of its own and not the same as not asking. + */ + const startPath = + siteStore.overlayOpts?.folderPath ?? pageStore.path.split('/').slice(0, -1).join('/') + const startParts = startPath ? startPath.split('/') : [] await loadTree({ - parentPath, + parentPath: startPath, initLoad: true }) - // -> Open tree up to current folder - const folderFolderPath = pathParts.slice(0, -2).join('/') - const folderFileName = pathParts.at(-2) + // -> Open tree up to that folder + const folderFolderPath = startParts.slice(0, -1).join('/') + const folderFileName = startParts.at(-1) for (const [id, node] of Object.entries(state.treeNodes)) { - if ( - parentPath.startsWith(node.folderPath ? `${node.folderPath}/${node.fileName}` : node.fileName) - ) { + const nodePath = node.folderPath ? `${node.folderPath}/${node.fileName}` : node.fileName + // -> Whole segments: `guides` is an ancestor of `guides/setup`, not of `guidesextra` + if (`${startPath}/`.startsWith(`${nodePath}/`)) { treeComp.value.setOpened(id) } } - // -> Switch to current folder (from page path) + // -> Switch to that folder const currentNode = Object.entries(state.treeNodes).find( ([, n]) => n.folderPath === folderFolderPath && n.fileName === folderFileName ) diff --git a/frontend/src/components/ImageEditDialog.vue b/frontend/src/components/ImageEditDialog.vue deleted file mode 100644 index 1d854ec3b..000000000 --- a/frontend/src/components/ImageEditDialog.vue +++ /dev/null @@ -1,153 +0,0 @@ - - - diff --git a/frontend/src/components/ImagePropertiesDialog.vue b/frontend/src/components/ImagePropertiesDialog.vue new file mode 100644 index 000000000..1be502c75 --- /dev/null +++ b/frontend/src/components/ImagePropertiesDialog.vue @@ -0,0 +1,310 @@ + + + diff --git a/frontend/src/components/shared/WInput.vue b/frontend/src/components/shared/WInput.vue index f50f61295..021a66cbd 100644 --- a/frontend/src/components/shared/WInput.vue +++ b/frontend/src/components/shared/WInput.vue @@ -205,7 +205,11 @@ const props = defineProps({ type: String, default: null }, - /** Bordered style. Retained as a prop because the markup sets it explicitly nearly everywhere. */ + /** + * The field on a white surface, with its label riding the border. Without it the field is FILLED: a + * light grey surface, for contrast against the white card it usually sits on, with the label above. + * Both are framed all the way round. + */ outlined: { type: Boolean, default: false @@ -402,8 +406,8 @@ const describedBy = computed(() => (showsBottom.value ? `${inputId}-desc` : unde /* A label on an outlined field rides the outline, Material-style, instead of sitting above it: at rest it stands in the middle of the field, and on focus or once there is a value it rises into the top - border. The non-outlined (filled) variant keeps its label above, since there is no outline to rise - into -- only three call sites in the app are labelled and not outlined. + border. The filled variant keeps its label above, where it reads against the grey surface rather + than interrupting the frame -- only a handful of call sites in the app are labelled and filled. */ const hasFloatingLabel = computed(() => Boolean(props.label) && props.outlined) @@ -460,12 +464,10 @@ const controlClasses = computed(() => [ `transparent` opts out, for the surfaces where that reasoning inverts -- see the prop. */ props.transparent - ? props.outlined - ? '' - : 'rounded-b-none' + ? '' : props.outlined ? 'bg-white dark:bg-black/20' - : 'rounded-b-none bg-black/4 dark:bg-white/6', + : 'bg-black/4 dark:bg-white/6', props.disable || props.disabled ? 'pointer-events-none opacity-60' : '', // -> Says the field is not the reader's to change; see the stripe rule in `tailwind.css` props.readonly || props.disable || props.disabled ? 'w-input-control--locked' : '', @@ -511,10 +513,22 @@ const controlStyle = computed(() => { if (hasFloatingLabel.value) { return undefined } + /* + All the way round for both variants. The filled one used to draw only its bottom edge, Material's + underline, which on a grey surface inside a white dialog read as a smudge with a line under it + rather than as a box to type in. + + A filled field also gets a 2px band of the card's surface just inside the frame, so the grey sits + inset from its border rather than running up against it. A second inset shadow, listed after the + frame so the frame paints over it, and widened with the frame on focus so the band stays 2px. + Not on a `transparent` field, which has no fill to set apart. + */ + const frame = `inset 0 0 0 ${frameWidth.value}px ${frameColor.value}` + const hasInset = !props.outlined && !props.transparent return { - boxShadow: props.outlined - ? `inset 0 0 0 ${frameWidth.value}px ${frameColor.value}` - : `inset 0 -${frameWidth.value}px 0 0 ${frameColor.value}` + boxShadow: hasInset + ? `${frame}, inset 0 0 0 ${frameWidth.value + 2}px var(--w-input-inset)` + : frame } }) diff --git a/frontend/src/composables/imagePropertiesDialog.js b/frontend/src/composables/imagePropertiesDialog.js new file mode 100644 index 000000000..a2a276183 --- /dev/null +++ b/frontend/src/composables/imagePropertiesDialog.js @@ -0,0 +1,126 @@ +import { defineAsyncComponent, watch } from 'vue' +import { useI18n } from 'vue-i18n' + +import { dialog } from '@/composables/dialog' +import { notify } from '@/composables/notify' +import { FILES_PREFIX, assetPath } from '@/helpers/assets' +import { fileSrc } from '@/renderers/shared' +import { usePageStore } from '@/stores/page' +import { useSiteStore } from '@/stores/site' + +/** + * The Image Properties dialog, and the trip to the file manager and back that its browse button + * makes -- for any editor that offers it, whatever "an image" means to that editor. + * + * `found` is the editor's own handle on the image the dialog is over, carried through untouched and + * handed to `apply` along with the answer: a source range in the Markdown and AsciiDoc editors, a node + * position in the Visual one. + * + * Call it during `setup`, since it watches the overlay. Then `open()` the dialog, and call + * `takePick()` first thing in the editor's `insertAsset` handler. + * + * @param {object} opts + * @param {(found: any, values: object) => void} opts.apply Write the dialog's answer over the image. + */ +export function useImagePropertiesDialog({ apply }) { + const { t } = useI18n() + const pageStore = usePageStore() + const siteStore = useSiteStore() + + /** + * A dialog that handed off to the file manager, waiting to be opened again: the image it was over, + * and what had been filled in. See `browse`. + */ + let pending = null + + /** + * @param {any} found The editor's handle on the image. + * @param {object} values What the dialog opens on -- see `ImagePropertiesDialog`'s props. + */ + function open(found, values) { + dialog({ + component: defineAsyncComponent(() => import('@/components/ImagePropertiesDialog.vue')), + componentProps: { ...values, pagePath: pageStore.path } + }).onOk(({ browse: wantsBrowse, ...answer }) => { + if (wantsBrowse) { + browse(found, { ...values, ...answer }) + } else { + apply(found, answer) + } + }) + } + + /** + * The dialog's browse button: the file manager, with the dialog put aside until it closes. + * + * A hand-off rather than the overlay opening over the dialog. Both are `w-dialog`s, and a dialog + * left open underneath keeps its Escape listener -- one press in the file manager would dismiss the + * dialog the author was coming back to. So the dialog has already closed, handing over what was + * filled in, and the overlay watcher below opens it again with whatever the file manager answered. + */ + function browse(found, values) { + pending = { found, values } + siteStore.openFileManager({ insertMode: true, folderPath: folderOf(values.src) }) + } + + /** + * The folder an image's address points into, for the file manager to open on -- so that picking a + * different picture starts beside the one being replaced. + * + * Resolved the way the renderer resolves it (`fileSrc`), so an address relative to the page, one + * from the site root and a `/_files/` URL all land on the same folder. Null for anything that is not + * one of this wiki's files -- an external URL, an empty field -- which leaves the manager on its own + * default; empty for a file at the site root, which is a folder like any other. + */ + function folderOf(src) { + const resolved = fileSrc(String(src ?? '').trim(), pageStore.path) + if (!resolved?.startsWith(FILES_PREFIX)) { + return null + } + const path = resolved.slice(FILES_PREFIX.length).split(/[?#]/)[0] + try { + return decodeURIComponent(path).split('/').slice(0, -1).join('/') + } catch { + // -> A stray `%` that is not an escape: not a path this can say anything about + return null + } + } + + /** + * The file manager's answer, if a dialog is waiting on one -- in which case it is the dialog's and + * not the cursor's, and the editor must not insert it. Answers whether it was taken. + * + * Anything but a picture is refused, as the Visual editor's Replace does: an image pointed at a PDF + * is a broken image. The alt text follows the file only where the author had not written one. + */ + function takePick(opts) { + if (!pending) { + return false + } + if (opts.type !== 'asset' || !opts.mimeType?.startsWith('image/')) { + notify({ type: 'warning', message: t('editor.visual.image.replaceNotImage') }) + return true + } + pending.values.src = assetPath(opts.folderPath, opts.fileName) + pending.values.alt ||= opts.title ?? '' + return true + } + + /* + The file manager closing, picked or not, is what brings the dialog back. Waiting for it rather than + reopening from the pick is what keeps the dialog from opening underneath an overlay still on its way + out. + */ + watch( + () => siteStore.overlay, + (overlay) => { + if (!overlay && pending) { + const { found, values } = pending + pending = null + open(found, values) + } + } + ) + + return { open, takePick } +} diff --git a/frontend/src/composables/imagePropertiesLens.js b/frontend/src/composables/imagePropertiesLens.js new file mode 100644 index 000000000..e58dd5940 --- /dev/null +++ b/frontend/src/composables/imagePropertiesLens.js @@ -0,0 +1,136 @@ +import { useI18n } from 'vue-i18n' +import { minBy } from 'es-toolkit/array' +import * as monaco from 'monaco-editor' +import { Position, Range } from 'monaco-editor' + +import { useImagePropertiesDialog } from '@/composables/imagePropertiesDialog' +import { notify } from '@/composables/notify' + +/** + * The "Image Properties" code lens, for a source editor built on Monaco -- the Markdown and the + * AsciiDoc editor both draw it, over `useImagePropertiesDialog`. + * + * Everything here is the same for both: where the lens goes and how the answer is written over the + * image. What differs is only how an image is spelled, so each editor hands in its own three functions + * for that: + * + * - `findImages(text)`: every image it can offer a form for, each with a 1-based `line` and + * `column` and the `raw` source it occupies, which has to sit on that one line. + * - `imageValues(found)`: what the dialog opens on -- see `ImagePropertiesDialog`'s props. + * - `writeImage(found, values)`: the source to replace `raw` with. + * + * Call it during `setup`, since it watches the overlay. Then `register()` once the editor exists, + * `takePick()` first thing in the editor's `insertAsset` handler, and `dispose()` with the editor. + * + * @param {object} opts + * @param {() => object} opts.getEditor The Monaco editor, once there is one. + * @param {string} opts.languageId What the lens provider is registered against. + * @param {(text: string) => Array} opts.findImages + * @param {(found: object) => object} opts.imageValues + * @param {(found: object, values: object) => string} opts.writeImage + */ +export function useImagePropertiesLens({ + getEditor, + languageId, + findImages, + imageValues, + writeImage +}) { + const { t } = useI18n() + const { open, takePick } = useImagePropertiesDialog({ apply }) + + /** The lens provider, which is registered against the language rather than this editor. */ + let provider = null + + /* + "Image Properties" over every image in the page, for the reason tables and blocks have a lens: an + image's size, alignment and framing are syntax nobody remembers. + + A line holding several images gets a lens for each, numbered, since otherwise there would be + nothing to tell "Image Properties | Image Properties" apart. The source goes with the line, for the + reason `edit` gives. + + The PROVIDER is per-language and process-wide, so it has to be disposed with the component or a + second visit to the editor would draw every lens twice. + */ + function register() { + const editor = getEditor() + const command = editor.addCommand(0, (_accessor, line, raw) => edit(line, raw)) + provider = monaco.languages.registerCodeLensProvider(languageId, { + provideCodeLenses(model) { + const images = findImages(model.getValue()) + const lenses = images.map((image) => { + const onLine = images.filter((other) => other.line === image.line) + return { + range: new Range(image.line, 1, image.line, 1), + command: { + id: command, + title: + onLine.length > 1 + ? t('editor.markup.imagePropertiesNth', { n: onLine.indexOf(image) + 1 }) + : t('editor.markup.imageProperties'), + arguments: [image.line, image.raw] + } + } + }) + return { lenses, dispose() {} } + } + }) + } + + function dispose() { + provider?.dispose() + provider = null + } + + /** + * The dialog, over an image already in the page — what the lens above one opens. + * + * Looked up again at the moment of the click rather than taken from the lens, which is provided once + * and then moves with the text. Matched on its source as well as its line: a line can hold several + * images, and an edit to the left of one moves its column without moving its line. + */ + function edit(line, raw) { + const found = findImages(getEditor().getModel().getValue()).find( + (entry) => entry.line === line && entry.raw === raw + ) + if (found) { + open(found, imageValues(found)) + } + } + + /** + * The dialog's answer, over the characters the image occupies and nothing else, as one undo. + * + * Found again first: the dialog may have been open a while, a trip to the file manager included, and + * in a collaborative session somebody else may have been typing all along. The same source nearest + * the line it was on is the same image; where there is none, it was edited away under the dialog, + * and writing over whatever is at that position now would destroy something else. + */ + function apply(found, values) { + const editor = getEditor() + const candidates = findImages(editor.getModel().getValue()).filter( + (entry) => entry.raw === found.raw + ) + const current = minBy(candidates, (entry) => Math.abs(entry.line - found.line)) + if (!current) { + notify({ type: 'warning', message: t('editor.markup.image.gone') }) + return + } + editor.executeEdits('image', [ + { + range: new Range( + current.line, + current.column, + current.line, + current.column + current.raw.length + ), + text: writeImage(current, values) + } + ]) + editor.setPosition(new Position(current.line, current.column)) + editor.focus() + } + + return { register, takePick, dispose } +} diff --git a/frontend/src/css/_page-contents-asciidoc.scss b/frontend/src/css/_page-contents-asciidoc.scss index 48ccd72ed..0c7801d5e 100644 --- a/frontend/src/css/_page-contents-asciidoc.scss +++ b/frontend/src/css/_page-contents-asciidoc.scss @@ -133,6 +133,55 @@ text-align: start; } + /* -- Images ------------------------------------------------------------------ */ + + /* + Alignment and framing, which Asciidoctor puts on an image's WRAPPER rather than on the picture -- + `div.imageblock` for a block image, `span.image` for an inline one: `align=center` as + `text-center`, `float=right` as `right`, and each role as itself. The same choices the markdown + classes in `_page-contents.scss` make (`align-*` and `decor-*` on the `img`), reached through the + wrapper; the Image Properties lens writes either form (`helpers/asciidocImages.js`). + + Centred by margins as well as by `text-align`, since whether an `img` here is a block or inline is + the reset's business and not this rule's. + */ + .imageblock { + &.text-center { + text-align: center; + + img { + margin-inline: auto; + } + } + &.text-right { + text-align: right; + + img { + margin-inline-start: auto; + } + } + } + .imageblock, + span.image { + &.left { + float: left; + margin: 0.3em 1.2em 0.8em 0; + } + &.right { + float: right; + margin: 0.3em 0 0.8em 1.2em; + } + &.decor-shadow img { + box-shadow: var(--content-image-shadow); + } + &.decor-border img { + border: 1px solid var(--content-rule-strong); + } + &.decor-rounded img { + border-radius: 12px; + } + } + /* -- Boxed blocks ------------------------------------------------------------ */ /* diff --git a/frontend/src/css/_page-contents.scss b/frontend/src/css/_page-contents.scss index d19995223..c9e37a818 100644 --- a/frontend/src/css/_page-contents.scss +++ b/frontend/src/css/_page-contents.scss @@ -160,6 +160,8 @@ half of a table's shadow repeated that often reads as a smudge behind the whole list. */ --content-links-shadow: 0 1px 3px rgba(0, 0, 0, 0.14); + /* Under an image framed with `decor-shadow`: deeper than a table's, since a photograph is not a tint */ + --content-image-shadow: 0 2px 4px rgba(0, 0, 0, 0.08), 0 6px 18px rgba(0, 0, 0, 0.14); /* The box behind the tick of a done task-list item; the tick itself is white in both themes */ --content-tick: #5b616b; @@ -333,6 +335,7 @@ --content-links-sheen: rgba(255, 255, 255, 0.045); /* -> Deepened, and no wider, for the reason the table's is: a 7% black drop is invisible here */ --content-links-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); + --content-image-shadow: 0 2px 4px rgba(0, 0, 0, 0.4), 0 6px 18px rgba(0, 0, 0, 0.45); /* Lighter than the light theme's, because the box has to be seen against a dark page -- but held @@ -2003,6 +2006,24 @@ } } + /* + How an image is framed, which the Image Properties dialog offers alongside the alignment classes + above (`IMAGE_STYLES` in `helpers/markdownImages.js`). Built on the content tokens, so a framed + screenshot follows the page into dark mode with the rest of the article. An AsciiDoc image carries + the same classes as roles on its wrapper instead -- see `_page-contents-asciidoc.scss`. + */ + img { + &.decor-shadow { + box-shadow: var(--content-image-shadow); + } + &.decor-border { + border: 1px solid var(--content-rule-strong); + } + &.decor-rounded { + border-radius: 12px; + } + } + figure { margin: 1.5em 0; text-align: center; diff --git a/frontend/src/css/tailwind.css b/frontend/src/css/tailwind.css index 224441a18..a64c06a2e 100644 --- a/frontend/src/css/tailwind.css +++ b/frontend/src/css/tailwind.css @@ -461,6 +461,11 @@ --w-input-ring: rgb(0 0 0 / 0.24); /* Pointer-over: a full-strength edge, so the field reads as reachable before it is focused */ --w-input-ring-hover: var(--color-black); + /* + The band a filled field draws just inside its frame (see `controlStyle` in WInput), in the + card's own surface colour so that it reads as a gap between the frame and the grey fill. + */ + --w-input-inset: var(--color-white); /* .36s cubic-bezier(.4,0,.2,1) is Quasar's own field transition, kept so the timing matches */ transition: box-shadow 0.36s cubic-bezier(0.4, 0, 0.2, 1), @@ -470,6 +475,7 @@ body.body--dark .w-input-control { --w-input-ring: rgb(255 255 255 / 0.3); --w-input-ring-hover: var(--color-white); + --w-input-inset: var(--color-dark-3); } /* diff --git a/frontend/src/editor/visual/images.js b/frontend/src/editor/visual/images.js index bf09c706e..74d7ce4eb 100644 --- a/frontend/src/editor/visual/images.js +++ b/frontend/src/editor/visual/images.js @@ -1,5 +1,7 @@ import { NodeSelection, Plugin } from 'prosemirror-state' +import { applyClassValues } from '@/helpers/markdownImages' + import { barButton } from './bar' import { schema } from './schema' @@ -55,20 +57,43 @@ function setImageAttrs(view, pos, attrs) { return true } +/** The classes on an image node, which are what its alignment and framing are written as. */ +export function imageClasses(node) { + return String(node.attrs.mdAttrs?.class ?? '') + .split(/\s+/) + .filter(Boolean) +} + /** - * What the image dialog edits: the text that stands in for the picture, and how big it is drawn. + * What the Image Properties dialog edits: where the picture loads from, the text that stands in for + * it, how big it is drawn, and its alignment and framing. * * 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. + * + * Alignment and framing are classes in `mdAttrs`, which is the `{.class}` suffix the serialiser + * writes -- so `applyClassValues` decides them exactly as it does for the Markdown editor's lens, + * keeping any class the dialog does not own. An id or a target in the same braces is untouched. */ -export function applyImageEdit(view, pos, { alt, width, height }) { +export function applyImageEdit(view, pos, { src, alt, width, height, alignment, styles }) { + const node = view.state.doc.nodeAt(pos) + if (!node || node.type !== schema.nodes.image) { + return false + } + const classes = applyClassValues(imageClasses(node), { alignment, styles }).join(' ') + const mdAttrs = { ...node.attrs.mdAttrs, class: classes } + if (!classes) { + delete mdAttrs.class + } return setImageAttrs(view, pos, { + src, alt: alt || null, width: width || null, - height: height || null + height: height || null, + mdAttrs: Object.keys(mdAttrs).length > 0 ? mdAttrs : null }) } @@ -92,7 +117,7 @@ export function removeImage(view, pos) { * 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.onEdit Open the Image Properties dialog. * @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 diff --git a/frontend/src/editor/visual/nodeviews.js b/frontend/src/editor/visual/nodeviews.js index 918780d80..221742978 100644 --- a/frontend/src/editor/visual/nodeviews.js +++ b/frontend/src/editor/visual/nodeviews.js @@ -3,6 +3,7 @@ import { TextSelection } from 'prosemirror-state' import { twemojiHtml } from '@/renderers/markdown' import { fileSrc } from '@/renderers/shared' +import { imageClasses } from './images' import { schema } from './schema' /** @@ -651,16 +652,24 @@ class CodeBlockView { * A page's source points at a picture the way a file beside it would — `photo.png` — and the renderer * resolves that to `/_files/…` at render time. The same resolution is used here, so the editor shows * the picture rather than a broken icon, while the source keeps the path that was written. + * + * Its classes are drawn too -- the alignment and framing the Image Properties dialog sets -- so that + * the content stylesheet floats, centres and frames it here as it will on the page. Written as the + * whole `className` on every update, which is why the selection is remembered rather than read back. */ class ImageView { constructor(node, view, getPos, context) { this.node = node this.context = context + this.selected = false this.dom = document.createElement('img') this.apply(node) } apply(node) { + this.dom.className = [...imageClasses(node), this.selected ? 'is-selected' : ''] + .filter(Boolean) + .join(' ') this.dom.src = fileSrc(node.attrs.src, this.context.pagePath()) this.dom.alt = node.attrs.alt ?? '' this.dom.title = node.attrs.title ?? '' @@ -686,10 +695,12 @@ class ImageView { } selectNode() { + this.selected = true this.dom.classList.add('is-selected') } deselectNode() { + this.selected = false this.dom.classList.remove('is-selected') } } diff --git a/frontend/src/editor/visual/schema.js b/frontend/src/editor/visual/schema.js index 087666fa0..9da42a243 100644 --- a/frontend/src/editor/visual/schema.js +++ b/frontend/src/editor/visual/schema.js @@ -74,7 +74,12 @@ export function readMdAttrs(token, skipClasses = []) { continue } } - out[name] = value + /* + MDC hands `{.a .b}` over as one `class` entry PER class, where markdown-it-attrs merges them into + one -- so a repeated class is added to what is there rather than replacing it, which left only the + last class of an image or a link standing. + */ + out[name] = name === 'class' && out.class ? `${out.class} ${value}` : value } return Object.keys(out).length > 0 ? out : null } diff --git a/frontend/src/editor/visual/serialize.js b/frontend/src/editor/visual/serialize.js index 34e97b4d5..f22d7f713 100644 --- a/frontend/src/editor/visual/serialize.js +++ b/frontend/src/editor/visual/serialize.js @@ -278,19 +278,25 @@ const nodes = { }, image(state, node) { - const src = node.attrs.src.replace(/[()]/g, '\\$&') + // -> A space would end the destination, so such an address goes in angle brackets + const src = /\s/.test(node.attrs.src) + ? `<${node.attrs.src.replace(/[<>]/g, '\\$&')}>` + : 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. + + It goes AFTER the title. The plugin reads the title first and the size second, so the other order + is not an image at all: `![a](x.png =300x "t")` renders as the literal text. */ const size = node.attrs.width || node.attrs.height ? ` =${node.attrs.width ?? ''}x${node.attrs.height ?? ''}` : '' state.write( - `![${state.esc(node.attrs.alt || '')}](${src}${size}${title})${writeMdAttrs(node.attrs.mdAttrs)}` + `![${state.esc(node.attrs.alt || '')}](${src}${title}${size})${writeMdAttrs(node.attrs.mdAttrs)}` ) }, diff --git a/frontend/src/helpers/asciidocImages.js b/frontend/src/helpers/asciidocImages.js new file mode 100644 index 000000000..7a99c9702 --- /dev/null +++ b/frontend/src/helpers/asciidocImages.js @@ -0,0 +1,296 @@ +import { asciidocQuoteValue } from '@/helpers/blocks' +import { IMAGE_STYLES } from '@/helpers/markdownImages' + +/** + * The images a page written in AsciiDoc already carries, read back and rewritten. + * + * The twin of `markdownImages.js`, for the "Image Properties" lens in the AsciiDoc editor. The dialog + * is the same one and speaks the same values; this is only how an image is spelled here: + * + * image::path/to/file.png[Alt text,640,480,role=decor-shadow,align=center] + * An inline image:path/to/file.png[Alt,32,float=right] in a sentence. + * + * The first three positional attributes are the alt text, the width and the height. Alignment is + * AsciiDoc's own -- `align=center` and `float=right`, which Asciidoctor turns into `text-center` and + * `right` on the image's wrapper -- rather than a role, so a page reads the same to any other AsciiDoc + * tool. The framing is roles, the same `decor-*` classes the markdown side writes; Asciidoctor puts a + * role on the wrapper as well, which `_page-contents.scss` styles alongside the markdown form. + * + * Three things about the attribute list are not what they look like, and Asciidoctor was asked rather + * than guessed at for each: + * + * - A missing alt text is the file's name, while `""` is an empty one -- which is what an image that + * is decoration wants. So an image that had none keeps none unless the field is actually edited. + * - `\]` is an escape in an INLINE image and a literal backslash in a block one. A block macro owns + * its whole line and ends at the last `]` on it, so a `]` in its alt text needs nothing. + * - An attribute's position counts every entry before it, named ones included, so `alt=X,640` is a + * width of 640. + */ + +/** A verbatim delimiter -- listing, literal, passthrough or comment. Nothing inside one is markup. */ +const VERBATIM_DELIMITER = /^(-{4,}|\.{4,}|\+{4,}|\/{4,})[ \t]*$/ + +/** A block image, which owns its line from the first column. Indented, it is a literal block. */ +const BLOCK_IMAGE = /^image::(\S|\S.*?\S)\[(.*)\][ \t]*$/ + +/** + * An inline image, as Asciidoctor's own `InlineImageMacroRx` reads one: the target may hold a space + * but neither starts nor ends with one, and the list ends at the first `]` that is not escaped. + */ +const INLINE_IMAGE = /(\\?)image:([^:\s[](?:[^\n[]*[^\s[])?)\[((?:\\.|[^\]\\])*)\]/g + +/** A named attribute's name. Anything else in front of an `=` is part of a positional value. */ +const NAME = /^[A-Za-z_][\w-]*$/ + +/** + * What the dialog's alignment means here. Center is a BLOCK alignment -- an inline image is part of a + * line of text and Asciidoctor has nothing to centre it with -- so an inline one is offered Left and + * Right only, through the dialog's `alignments`. + */ +const BLOCK_ALIGNMENTS = ['', 'center', 'right'] +const INLINE_ALIGNMENTS = ['', 'right'] + +/** + * Split an attribute list into its entries, each with the slot it occupies. + * + * @param {string} source The inside of the brackets. + * @param {boolean} inline Whether `\]` is an escape -- see the note at the top. + * @returns {Array<{ name: string|null, value: string, quoted: boolean, raw: string }>} + */ +function parseAttributes(source, inline) { + const entries = [] + let index = 0 + while (index <= source.length && source.trim()) { + // -> One entry: up to the next comma that is not inside quotes + let end = index + let quote = null + for (; end < source.length; end++) { + const char = source[end] + if (char === '\\') { + end++ + } else if (quote) { + quote = char === quote ? null : quote + } else if (char === '"' || char === "'") { + // -> Only where a value starts: a quote in the middle of a word is just a character + const sofar = source.slice(index, end).trim() + quote = sofar === '' || sofar.endsWith('=') ? char : null + } else if (char === ',') { + break + } + } + const raw = source.slice(index, end).trim() + const equals = raw.indexOf('=') + const named = equals > 0 && NAME.test(raw.slice(0, equals).trim()) + const text = named ? raw.slice(equals + 1).trim() : raw + const quoted = /^(["']).*\1$/s.test(text) && text.length > 1 + let value = quoted ? text.slice(1, -1).replace(/\\(["'\\])/g, '$1') : text + if (inline) { + value = value.replace(/\\\]/g, ']') + } + entries.push({ name: named ? raw.slice(0, equals).trim() : null, value, quoted, raw }) + index = end + 1 + } + return entries +} + +/** The value of attribute `name`, or of the positional one at `slot` (0-based), or null. */ +function attribute(entries, name, slot) { + const named = entries.find((entry) => entry.name === name) + if (named) { + return named + } + const positional = entries[slot] + return positional && positional.name === null ? positional : null +} + +/** The image whose macro was matched, in the shape the lens and the writer both work from. */ +function describe({ line, column, raw, inline, target, attrlist }) { + const entries = parseAttributes(attrlist, inline) + const alt = attribute(entries, 'alt', 0) + const roles = (entries.find((entry) => entry.name === 'role')?.value ?? '') + .split(/\s+/) + .filter(Boolean) + const align = entries.find((entry) => entry.name === 'align')?.value ?? '' + const float = entries.find((entry) => entry.name === 'float')?.value ?? '' + return { + line, + column, + raw, + inline, + src: target, + // -> Whether there was one at all, as opposed to an empty one -- see the note at the top + hasAlt: Boolean(alt) && (alt.value !== '' || alt.quoted), + alt: alt?.value ?? '', + width: attribute(entries, 'width', 1)?.value ?? '', + height: attribute(entries, 'height', 2)?.value ?? '', + roles, + align, + float, + entries + } +} + +/** + * Every image in the source, in the order they appear. Line and column are 1-based, to be handed + * straight to the editor. + * + * An image inside a verbatim block or a comment line is a code sample or a note and not an image, so + * those are skipped -- the same reading `findBlocks` takes. An escaped `\image:` is literal text. + * + * @param {string} text The page source. + * @returns {Array} See `describe`. + */ +export function findImages(text) { + const lines = text.split('\n') + const images = [] + let verbatim = null + + for (let index = 0; index < lines.length; index++) { + const line = lines[index] + if (verbatim) { + if (line.trimEnd() === verbatim) { + verbatim = null + } + continue + } + const fence = VERBATIM_DELIMITER.exec(line) + if (fence) { + verbatim = fence[1] + continue + } + if (line.startsWith('//') || !line.includes('image:')) { + continue + } + + const block = BLOCK_IMAGE.exec(line) + if (block) { + images.push( + describe({ + line: index + 1, + column: 1, + raw: line.trimEnd(), + inline: false, + target: block[1], + attrlist: block[2] + }) + ) + continue + } + for (const match of line.matchAll(INLINE_IMAGE)) { + if (match[1]) { + continue + } + images.push( + describe({ + line: index + 1, + column: match.index + 1, + raw: match[0], + inline: true, + target: match[2], + attrlist: match[3] + }) + ) + } + } + return images +} + +/** + * What the dialog should open on, for an image `findImages` read. + * + * @param {object} found An image from `findImages`. + * @returns {object} The dialog's props. + */ +export function imageValues(found) { + return { + src: found.src, + alt: found.alt, + width: found.width, + height: found.height, + alignment: + found.float === 'right' ? 'right' : !found.inline && found.align === 'center' ? 'center' : '', + alignments: found.inline ? INLINE_ALIGNMENTS : BLOCK_ALIGNMENTS, + styles: IMAGE_STYLES.filter((name) => found.roles.includes(name)) + } +} + +/** A value written into the list, quoted only where it would otherwise be misread. */ +function writeValue(value, inline) { + const text = /^\s|\s$|[,"'=]/.test(value) ? asciidocQuoteValue(value) : value + return inline ? text.replaceAll(']', '\\]') : text +} + +/** + * The image written back out, from what the dialog answered. + * + * Applying the dialog without touching anything gives back exactly what was read. Otherwise the alt + * text, width and height are written as the first three positional attributes; every other attribute + * stays as it was written, in its own order; and the role, `align` and `float` go last. Roles the dialog + * does not manage are kept, and so is an `align` or a `float` it has no option for -- `float=left` on + * an image opened only to resize it is not the dialog's to take away. + * + * @param {object} found What `findImages` read. + * @param {{ src: string, alt: string, width: string, height: string, alignment: string, + * styles: string[] }} values What the dialog answered. + * @returns {string} The AsciiDoc for the image. + */ +export function writeImage(found, values) { + const before = imageValues(found) + const unchanged = + ['src', 'alt', 'width', 'height', 'alignment'].every((key) => values[key] === before[key]) && + values.styles.length === before.styles.length && + values.styles.every((name) => before.styles.includes(name)) + if (unchanged) { + return found.raw + } + + const { inline } = found + const altText = + values.alt === '' + ? found.hasAlt || values.alt !== before.alt + ? '""' + : '' + : writeValue(values.alt, inline) + const slots = [altText, values.width, values.height] + while (slots.length > 0 && slots.at(-1) === '') { + slots.pop() + } + + const managed = new Set(['alt', 'width', 'height', 'role', 'align', 'float']) + const others = found.entries + .filter((entry, index) => (entry.name === null ? index > 2 : !managed.has(entry.name))) + .map((entry) => entry.raw) + + const roles = found.roles.filter( + (name) => !IMAGE_STYLES.includes(name) || values.styles.includes(name) + ) + roles.push(...values.styles.filter((name) => !roles.includes(name))) + + let align = found.align + let float = found.float + if (values.alignment === 'center') { + align = 'center' + float = '' + } else if (values.alignment === 'right') { + align = '' + float = 'right' + } else { + align = align === 'center' ? '' : align + float = float === 'right' ? '' : float + } + + const list = [ + ...slots, + ...others, + roles.length > 1 ? `role=${asciidocQuoteValue(roles.join(' '))}` : '', + roles.length === 1 ? `role=${writeValue(roles[0], inline)}` : '', + align ? `align=${align}` : '', + float ? `float=${float}` : '' + ] + // -> Empty positional slots hold their place; anything after them that came out empty does not + const attrlist = [...list.slice(0, slots.length), ...list.slice(slots.length).filter(Boolean)] + .join(',') + .replace(/^,+$/, '') + + return `image:${inline ? '' : ':'}${values.src}[${attrlist}]` +} diff --git a/frontend/src/helpers/markdownImages.js b/frontend/src/helpers/markdownImages.js new file mode 100644 index 000000000..3e2b7860a --- /dev/null +++ b/frontend/src/helpers/markdownImages.js @@ -0,0 +1,346 @@ +/** + * The images already in a page's markdown source, read back and rewritten. + * + * What the "Image Properties" lens in the Markdown editor works from: `findImages` says where each one + * is and what it says, `writeImage` writes the answer back over exactly the characters it came from. + * + * Only the inline form, `![alt](src "title" =WxH){.class}`, and only where it sits on one line. A + * reference image (`![alt][ref]`) keeps its address somewhere else in the page, and markdown lets an + * inline one break across lines -- both are rare enough in practice that offering a form over them is + * not worth reading them. Nothing is lost by it: they simply get no lens. + * + * The grammar is the one `markdown-it-imsize` parses, which replaces markdown-it's own image rule: + * the title comes BEFORE the size, and the size needs a space in front of it. The braces after the + * closing parenthesis are MDC's inline props, which is the reading the renderer gives a brace that + * abuts what precedes it -- see `renderers/markdown.js`. + */ + +/** The opening or closing line of a fenced block, indented up to the three spaces markdown allows. */ +const FENCE = /^ {0,3}(`{3,}|~{3,})/ + +/** `=WxH`, with either half optional -- exactly what `parseImageSize` in `markdown-it-imsize` takes. */ +const SIZE = /^=(\d[\d%]*)?x([\d%]*)/ + +/** A `.class` in a props list. Anything else in there -- an `#id`, a `key=value` -- is not ours. */ +const CLASS = /(?:^|\s)\.([^\s.#"'=}]+)/g + +/** + * The classes the dialog sets, which are therefore the classes it is allowed to take away -- by the + * dialog's own name for each alignment, since the AsciiDoc side spells the same two differently. + * + * `align-left` is deliberately not among them: "Left" is what an image with no alignment does, so the + * dialog writes no class for it -- but an image somebody floated left by hand keeps its float unless + * they pick another alignment, rather than losing it to a dialog opened only to change its size. + */ +const ALIGNMENT_CLASSES = { center: 'align-center', right: 'align-right' } + +/** + * The framing classes, shared with the AsciiDoc side, where they are written as roles. Both renderers + * end up with them as classes, which `_page-contents.scss` styles. + */ +export const IMAGE_STYLES = ['decor-shadow', 'decor-border', 'decor-rounded'] + +/** Index of the bracket closing the one at `start`, or -1. Brackets nest; a backslash escapes. */ +function closingBracket(line, start) { + let depth = 0 + for (let index = start; index < line.length; index++) { + const char = line[index] + if (char === '\\') { + index++ + } else if (char === '[') { + depth++ + } else if (char === ']' && --depth === 0) { + return index + } + } + return -1 +} + +/** + * The link destination at `start`: `` or a run with balanced parentheses. + * + * @returns {{ src: string, raw: string, end: number } | null} The address as the author meant it -- + * angle brackets off, escapes undone -- as it was written, and where it stopped. + */ +function readDestination(line, start) { + if (line[start] === '<') { + for (let index = start + 1; index < line.length; index++) { + if (line[index] === '\\') { + index++ + } else if (line[index] === '>') { + const raw = line.slice(start, index + 1) + return { src: unescape(raw.slice(1, -1)), raw, end: index + 1 } + } else if (line[index] === '<') { + return null + } + } + return null + } + let depth = 0 + let index = start + for (; index < line.length; index++) { + const char = line[index] + if (char === '\\') { + index++ + } else if (char === '(') { + depth++ + } else if (char === ')') { + if (depth === 0) { + break + } + depth-- + } else if (/\s/.test(char)) { + break + } + } + const raw = line.slice(start, index) + return depth === 0 ? { src: unescape(raw), raw, end: index } : null +} + +/** A link title, `"…"`, `'…'` or `(…)`, as written -- quotes and all, so it goes back untouched. */ +function readTitle(line, start) { + const close = { '"': '"', "'": "'", '(': ')' }[line[start]] + if (!close) { + return null + } + for (let index = start + 1; index < line.length; index++) { + if (line[index] === '\\') { + index++ + } else if (line[index] === close) { + return { title: line.slice(start, index + 1), end: index + 1 } + } + } + return null +} + +/** The inside of `{…}` at `start`, if one opens there. A `}` inside quotes does not close it. */ +function readProps(line, start) { + if (line[start] !== '{') { + return null + } + let quote = null + for (let index = start + 1; index < line.length; index++) { + const char = line[index] + if (quote) { + quote = char === quote ? null : quote + } else if (char === '"' || char === "'") { + quote = char + } else if (char === '}') { + return { props: line.slice(start + 1, index), end: index + 1 } + } + } + return null +} + +function skipSpaces(line, index) { + while (index < line.length && (line[index] === ' ' || line[index] === '\t')) { + index++ + } + return index +} + +function unescape(text) { + return text.replace(/\\([()<>[\]])/g, '$1') +} + +/** + * The image whose `![` is at `start`, read to the end of its props, or null where what follows is not + * an inline image after all. + */ +function readImage(line, start) { + const labelEnd = closingBracket(line, start + 1) + if (labelEnd < 0 || line[labelEnd + 1] !== '(') { + return null + } + let index = skipSpaces(line, labelEnd + 2) + const destination = readDestination(line, index) + if (!destination) { + return null + } + index = destination.end + + let title = '' + let afterSpace = skipSpaces(line, index) + if (afterSpace > index) { + const found = readTitle(line, afterSpace) + if (found) { + title = found.title + index = found.end + afterSpace = skipSpaces(line, index) + } + } + + let width = '' + let height = '' + if (afterSpace > index) { + const size = SIZE.exec(line.slice(afterSpace)) + if (size) { + width = size[1] ?? '' + height = size[2] ?? '' + index = afterSpace + size[0].length + } + } + + index = skipSpaces(line, index) + if (line[index] !== ')') { + return null + } + index++ + + const props = readProps(line, index) + const classes = props ? [...props.props.matchAll(CLASS)].map((match) => match[1]) : [] + return { + column: start + 1, + raw: line.slice(start, props?.end ?? index), + alt: unescape(line.slice(start + 2, labelEnd)), + rawAlt: line.slice(start + 2, labelEnd), + src: destination.src, + rawSrc: destination.raw, + title, + width, + height, + classes, + // -> Whatever else the braces held, verbatim and in order, for `writeImage` to put back + otherProps: props ? props.props.replace(CLASS, '').trim() : '' + } +} + +/** + * Every inline image in the source, in the order they appear. Line and column are 1-based, to be + * handed straight to the editor. + * + * An image inside a fenced code block or a code span is a code sample and not an image, so those are + * skipped -- the same reading `findEditableTables` and `findBlocks` take of the same lines. An image + * inside a link (`[![badge](…)](…)`) is still an image, and is found. + * + * @param {string} text The page source. + * @returns {Array<{ line: number, column: number, raw: string, alt: string, src: string, + * title: string, width: string, height: string, classes: string[], otherProps: string }>} + */ +export function findImages(text) { + const lines = text.split('\n') + const images = [] + let fence = null + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex] + const edge = FENCE.exec(line) + if (fence) { + if (edge && edge[1][0] === fence[0] && edge[1].length >= fence.length) { + fence = null + } + continue + } + if (edge) { + fence = edge[1] + continue + } + if (!line.includes('![')) { + continue + } + + for (let index = 0; index < line.length; index++) { + const char = line[index] + if (char === '\\') { + index++ + } else if (char === '`') { + // -> A code span closes on a run of exactly as many backticks; one that never closes is + // literal backticks, and the scan carries on after them + const run = /^`+/.exec(line.slice(index))[0] + const close = line.slice(index + run.length).search(new RegExp(`(? classes.includes(ALIGNMENT_CLASSES[key])) ?? '', + styles: IMAGE_STYLES.filter((name) => classes.includes(name)) + } +} + +/** + * An image's classes, with the dialog's alignment and styles applied. + * + * Classes the dialog does not manage are kept, in their own order, and so are the managed ones still + * wanted; new ones go at the end. `align-left` survives a "Left" -- see `ALIGNMENT_CLASSES`. + * + * @param {string[]} classes What the image has now. + * @param {{ alignment: string, styles: string[] }} values What the dialog answered. + * @returns {string[]} + */ +export function applyClassValues(classes, { alignment, styles }) { + const wanted = [ALIGNMENT_CLASSES[alignment], ...styles].filter(Boolean) + const managed = new Set([...Object.values(ALIGNMENT_CLASSES), ...IMAGE_STYLES]) + const kept = classes.filter((name) => + managed.has(name) ? wanted.includes(name) : !(alignment && name === 'align-left') + ) + return [...kept, ...wanted.filter((name) => !kept.includes(name))] +} + +/** + * The image written back out, from what the dialog answered. + * + * Whatever the dialog did not change is written exactly as it was read -- the label and the address + * with the author's own escaping, the title with its own quotes, the classes in their own order -- so + * that applying the dialog without touching anything leaves the source as it was. Classes the dialog + * does not manage are kept, and so is everything else in the braces; the braces are dropped + * altogether once nothing is left in them. + * + * @param {object} found What `findImages` read. + * @param {{ src: string, alt: string, width: string, height: string, alignment: string, + * styles: string[] }} values What the dialog answered. + * @returns {string} The markdown for the image. + */ +export function writeImage(found, { src, alt, width, height, alignment, styles }) { + const label = alt === found.alt ? found.rawAlt : alt.replace(/[[\]]/g, '\\$&') + let destination = found.rawSrc + if (src !== found.src) { + // -> A space would end the destination, so such an address goes in angle brackets + destination = /\s/.test(src) + ? `<${src.replace(/[<>]/g, '\\$&')}>` + : src.replace(/[()]/g, '\\$&') + } + const title = found.title ? ` ${found.title}` : '' + const size = width || height ? ` =${width}x${height}` : '' + + const classes = applyClassValues(found.classes, { alignment, styles }) + const props = [found.otherProps, ...classes.map((name) => `.${name}`)].filter(Boolean).join(' ') + + return `![${label}](${destination}${title}${size})${props ? `{${props}}` : ''}` +} diff --git a/frontend/src/stores/site.js b/frontend/src/stores/site.js index 0a5219ccf..9b42429d5 100644 --- a/frontend/src/stores/site.js +++ b/frontend/src/stores/site.js @@ -397,11 +397,18 @@ export const useSiteStore = defineStore('site', { } }, actions: { + /** + * @param {object} [opts] + * @param {boolean} [opts.insertMode] Pick something for the editor rather than browse. + * @param {?string} [opts.folderPath] The folder to open on, slash-separated and empty for the + * site root. Absent, the manager opens on the folder of the page being viewed. + */ openFileManager(opts) { this.$patch({ overlay: 'FileManager', overlayOpts: { - insertMode: opts?.insertMode ?? false + insertMode: opts?.insertMode ?? false, + folderPath: opts?.folderPath ?? null } }) },