mirror of https://github.com/requarks/wiki
parent
4a542e4449
commit
a3706d900e
@ -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>
|
||||||
@ -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 }
|
||||||
|
}
|
||||||
@ -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, `{.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 (`[](…)`) 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 `${props ? `{${props}}` : ''}`
|
||||||
|
}
|
||||||
Loading…
Reference in new issue