feat: image properties dialog + alignment + styles

scarlett
NGPixel 12 hours ago
parent 4a542e4449
commit a3706d900e
No known key found for this signature in database

@ -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…",

@ -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":"<path fill=\"currentColor\" d=\"M4.219 10.781L2.78 12.22l12.5 12.5l.719.687l.719-.687l12.5-12.5l-1.438-1.438L16 22.562z\"/>","width":32,"height":32},
@ -227,6 +227,7 @@ export const BUNDLED_ICONS = {
"mdi:file-tree-outline": {"body":"<path fill=\"currentColor\" d=\"M12 13H7v5h5v2H5V10h2v1h5zM8 4v2H4V4zm2-2H2v6h8zm10 9v2h-4v-2zm2-2h-8v6h8zm-2 9v2h-4v-2zm2-2h-8v6h8z\"/>","width":24,"height":24},
"mdi:fit-to-screen-outline": {"body":"<path fill=\"currentColor\" d=\"M17 4h3c1.1 0 2 .9 2 2v2h-2V6h-3zM4 8V6h3V4H4c-1.1 0-2 .9-2 2v2zm16 8v2h-3v2h3c1.1 0 2-.9 2-2v-2zM7 18H4v-2H2v2c0 1.1.9 2 2 2h3zm9-8v4H8v-4zm2-2H6v8h12z\"/>","width":24,"height":24},
"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:folder-image": {"body":"<path fill=\"currentColor\" d=\"m5 17l4.5-6l3.5 4.5l2.5-3L19 17m1-11h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2\"/>","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-left": {"body":"<path fill=\"currentColor\" d=\"M3 3h18v2H3zm0 4h12v2H3zm0 4h18v2H3zm0 4h12v2H3zm0 4h18v2H3z\"/>","width":24,"height":24},
@ -257,6 +258,7 @@ export const BUNDLED_ICONS = {
"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-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-5.04 9.29l-2.75 3.54l-1.96-2.36L6.5 17h11z\"/>","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},

@ -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()

@ -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()

@ -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.
*

@ -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
)

@ -1,153 +0,0 @@
<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,310 @@
<template>
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
<w-card style="width: 480px; max-width: 90vw">
<w-card-header>{{ t('editor.markup.imageProperties') }}</w-card-header>
<w-card-section class="flex flex-col gap-4">
<!--
What the address currently points at, so that picking a different file -- or mistyping one --
shows up here rather than only in the preview pane behind the dialog.
-->
<div
class="flex h-32 items-center justify-center overflow-hidden rounded bg-black/5 dark:bg-white/5">
<img
v-if="state.previewSrc && !state.previewFailed"
:key="state.previewSrc"
:src="state.previewSrc"
alt=""
class="max-h-full max-w-full object-contain"
@load="onPreviewLoad"
@error="onPreviewError" />
<div
v-else-if="state.previewFailed"
class="text-caption text-black/60 dark:text-white/70">
{{ t('editor.markup.image.previewFailed') }}
</div>
<w-icon v-else name="mdi:image-outline" size="48px" class="opacity-30" />
</div>
<w-input
v-model="state.src"
:label="t('editor.markup.image.src')"
:rules="[srcRule]"
hide-bottom-space
spellcheck="false">
<template #append>
<w-btn
flat
dense
round
icon="mdi:folder-image"
color="primary"
:aria-label="t('editor.markup.image.browse')"
@click="browse">
<w-tooltip>{{ t('editor.markup.image.browse') }}</w-tooltip>
</w-btn>
</template>
</w-input>
<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]"
hide-bottom-space
spellcheck="false" />
<w-input
class="flex-1"
v-model="state.height"
:label="t('editor.visual.image.height')"
:rules="[sizeRule]"
hide-bottom-space
spellcheck="false" />
</div>
<div class="-mt-2 text-caption text-black/60 dark:text-white/70">
{{ t('editor.visual.image.sizeHint') }}
<template v-if="state.naturalWidth > 0">
<br />
{{
t('editor.visual.image.natural', {
width: state.naturalWidth,
height: state.naturalHeight
})
}}
</template>
</div>
<div class="flex flex-col gap-2">
<div class="text-caption font-medium">{{ t('editor.markup.image.alignment') }}</div>
<div>
<w-btn-toggle
v-model="state.alignment"
push
glossy
no-caps
toggle-color="primary"
:aria-label="t('editor.markup.image.alignment')"
:options="alignmentOptions" />
</div>
<div class="text-caption text-black/60 dark:text-white/70">
{{ t('editor.markup.image.alignHint') }}
</div>
</div>
<div class="flex flex-col gap-2">
<div class="text-caption font-medium">{{ t('editor.markup.image.styles') }}</div>
<div class="flex flex-wrap gap-x-5 gap-y-2">
<w-checkbox
v-for="style of styleOptions"
:key="style.value"
v-model="state.styles"
:val="style.value"
:label="style.label" />
</div>
</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, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { debounce } from 'es-toolkit/function'
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
import { fileSrc } from '@/renderers/shared'
/**
* Everything about an image that is not the page around it: where it loads from, the words that stand
* in for it, how big it is drawn, where it sits and how it is framed. What the "Image Properties" lens
* opens in the Markdown and AsciiDoc editors, and the image bar's Edit in the Visual editor -- opened
* through `useImagePropertiesDialog`, which is also what carries the browse button's trip.
*
* Picking a different file is a hand-off rather than a second layer: the file manager is a full-screen
* overlay, and a dialog left open underneath it would still be listening for Escape. So the browse
* button closes this dialog with `browse: true` and everything typed so far, and it is opened again
* with the file that was picked -- or as it was, if nothing was.
*
* The alignment is answered as a word -- `center`, `right`, or empty for the default -- rather than
* as what any one syntax writes for it: markdown spells it as a class, AsciiDoc as `align` and
* `float`. `helpers/markdownImages.js` and `helpers/asciidocImages.js` each translate, and the Visual
* editor uses the markdown one. The styles are the `decor-*` classes everywhere, which the content
* stylesheets define.
*/
// PROPS
const props = defineProps({
/** The address as the source holds it, which may be relative to the page. */
src: {
type: String,
default: ''
},
/** 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: ''
},
/** `center`, `right`, or empty for an image left where the text puts it. */
alignment: {
type: String,
default: ''
},
/**
* Which of those to offer. An AsciiDoc image written inside a line of text has nothing to centre it
* with, so it is offered the other two.
*/
alignments: {
type: Array,
default: () => ['', 'center', 'right']
},
/** Any of `IMAGE_STYLES`. */
styles: {
type: Array,
default: () => []
},
/** The page being edited, which a relative address is resolved against for the preview. */
pagePath: {
type: String,
default: ''
}
})
// EMITS
defineEmits([...dialogComponentEmits])
// DIALOG
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
// I18N
const { t } = useI18n()
// DATA
const state = reactive({
src: props.src,
alt: props.alt,
width: props.width,
height: props.height,
alignment: props.alignment,
styles: [...props.styles],
previewSrc: fileSrc(props.src, props.pagePath),
previewFailed: false,
naturalWidth: 0,
naturalHeight: 0
})
// 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, and the whole suffix stops being a size.
*/
const SIZE = /^\d+%?$/
const alignmentOptions = computed(() =>
[
{ label: t('editor.markup.image.alignLeft'), value: '' },
{ label: t('editor.markup.image.alignCenter'), value: 'center' },
{ label: t('editor.markup.image.alignRight'), value: 'right' }
].filter((option) => props.alignments.includes(option.value))
)
const styleOptions = computed(() => [
{ label: t('editor.markup.image.styleShadow'), value: 'decor-shadow' },
{ label: t('editor.markup.image.styleBorder'), value: 'decor-border' },
{ label: t('editor.markup.image.styleRounded'), value: 'decor-rounded' }
])
const isValid = computed(
() =>
srcRule(state.src) === true && sizeRule(state.width) === true && sizeRule(state.height) === true
)
// METHODS
function srcRule(value) {
return String(value ?? '').trim() !== '' ? true : t('editor.markup.image.srcRequired')
}
function sizeRule(value) {
const text = String(value ?? '').trim()
return text === '' || SIZE.test(text) ? true : t('editor.visual.image.sizeInvalid')
}
function onPreviewLoad(ev) {
state.naturalWidth = ev.target.naturalWidth
state.naturalHeight = ev.target.naturalHeight
}
function onPreviewError() {
state.previewFailed = true
state.naturalWidth = 0
state.naturalHeight = 0
}
function values() {
return {
src: state.src.trim(),
// -> A lone space is emptiness, and emptiness is a value: an image that is decoration
alt: state.alt.trim(),
width: state.width.trim(),
height: state.height.trim(),
alignment: state.alignment,
styles: [...state.styles]
}
}
function browse() {
onDialogOK({ browse: true, ...values() })
}
function submit() {
onDialogOK({ browse: false, ...values() })
}
// WATCHERS
/*
Not on every keystroke: each new address is a request, and half an address typed is a 404 the
preview would flash up between letters.
*/
watch(
() => state.src,
debounce((src) => {
state.previewSrc = fileSrc(src.trim(), props.pagePath)
state.previewFailed = false
state.naturalWidth = 0
state.naturalHeight = 0
}, 400)
)
</script>

@ -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
}
})

@ -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 }
}

@ -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<object>} 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 }
}

@ -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 ------------------------------------------------------------ */
/*

@ -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;

@ -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);
}
/*

@ -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

@ -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')
}
}

@ -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
}

@ -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)}`
)
},

@ -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<object>} 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}]`
}

@ -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`: `<anything but a newline>` 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(`(?<!\`)${run}(?!\`)`))
index += close < 0 ? run.length - 1 : run.length * 2 + close - 1
} else if (char === '!' && line[index + 1] === '[') {
const image = readImage(line, index)
if (image) {
images.push({ line: lineIndex + 1, ...image })
index += image.raw.length - 1
}
}
}
}
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,
...classValues(found.classes)
}
}
/**
* The dialog's alignment and styles, read off an image's classes.
*
* Exported for the Visual editor, which holds the same classes on its image node (`mdAttrs.class`)
* and offers the same dialog over them.
*
* @param {string[]} classes
* @returns {{ alignment: string, styles: string[] }}
*/
export function classValues(classes) {
return {
alignment:
Object.keys(ALIGNMENT_CLASSES).find((key) => 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}}` : ''}`
}

@ -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
}
})
},

Loading…
Cancel
Save