mirror of
https://github.com/requarks/wiki.git
synced 2026-10-12 05:54:49 -05:00
fix: page scripts/styles + toggle styling in dark mode
This commit is contained in:
6 files changed
+344
-45
No files matched your search
@@ -350,8 +350,10 @@ class Pages {
|
||||
* Flatten a row and its blobs into the shape the API returns.
|
||||
*
|
||||
* @param locked Withhold the body — the source, the rendered HTML, the table of contents drawn from
|
||||
* it, and the relation links written onto the page. The metadata stays: a reader
|
||||
* looking at the lock screen is told what page they are being asked for a password to.
|
||||
* it, the relation links written onto the page, and the page's own CSS and scripts,
|
||||
* which the page view runs against content this reader has not been given. The
|
||||
* metadata stays: a reader looking at the lock screen is told what page they are
|
||||
* being asked for a password to.
|
||||
* @param withPassword Include the page's own password. Only for a requester who may edit the page,
|
||||
* which is the one that has to be able to read it back and save it again.
|
||||
* @param withContent Include the source. A redirection's comes back either way: its content is not
|
||||
@@ -405,9 +407,9 @@ class Pages {
|
||||
showTags: config.showTags ?? true,
|
||||
showToc: config.showToc ?? true,
|
||||
tocDepth: config.tocDepth ?? { min: 1, max: 2 },
|
||||
scriptJsLoad: scripts.jsLoad ?? '',
|
||||
scriptJsUnload: scripts.jsUnload ?? '',
|
||||
scriptCss: scripts.css ?? '',
|
||||
scriptJsLoad: locked ? '' : (scripts.jsLoad ?? ''),
|
||||
scriptJsUnload: locked ? '' : (scripts.jsUnload ?? ''),
|
||||
scriptCss: locked ? '' : (scripts.css ?? ''),
|
||||
navigationId: row.navigationId ?? null,
|
||||
navigationMode: row.navigationMode ?? 'inherit',
|
||||
authorId: row.authorId,
|
||||
|
||||
@@ -166,10 +166,16 @@
|
||||
<w-tooltip>{{ t('editor.props.localeRelationsHint') }}</w-tooltip>
|
||||
</w-btn>
|
||||
</w-card-section>
|
||||
<w-card-section class="alt-card" id="refCardScripts">
|
||||
<!--
|
||||
Only for an author who may actually write them: the server drops a script or a stylesheet from
|
||||
somebody without the permission rather than refusing the save, so left on show these buttons
|
||||
took an edit, closed on it, and lost it without a word.
|
||||
-->
|
||||
<w-card-section class="alt-card" id="refCardScripts" v-if="mayWriteScripts || mayWriteStyles">
|
||||
<div class="w-section-header">{{ t('editor.props.scripts') }}</div>
|
||||
<w-btn
|
||||
class="w-full"
|
||||
v-if="mayWriteScripts"
|
||||
:label="t(`editor.props.jsLoad`)"
|
||||
icon="la:js-square"
|
||||
no-caps
|
||||
@@ -180,6 +186,7 @@
|
||||
</w-btn>
|
||||
<w-btn
|
||||
class="w-full mt-2"
|
||||
v-if="mayWriteScripts"
|
||||
:label="t(`editor.props.jsUnload`)"
|
||||
icon="la:js-square"
|
||||
no-caps
|
||||
@@ -188,8 +195,11 @@
|
||||
@click="editScripts(`jsUnload`)">
|
||||
<w-tooltip>{{ t('editor.props.jsUnloadHint') }}</w-tooltip>
|
||||
</w-btn>
|
||||
<!-- -> The gap above it belongs to the buttons before it, so it goes when they do -->
|
||||
<w-btn
|
||||
class="w-full mt-2"
|
||||
class="w-full"
|
||||
:class="{ 'mt-2': mayWriteScripts }"
|
||||
v-if="mayWriteStyles"
|
||||
:label="t(`editor.props.styles`)"
|
||||
icon="la:css3-alt"
|
||||
no-caps
|
||||
@@ -355,6 +365,7 @@ import { useEditorStore } from '@/stores/editor'
|
||||
import { useFlagsStore } from '@/stores/flags'
|
||||
import { usePageStore } from '@/stores/page'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
|
||||
import IconPickerDialog from './IconPickerDialog.vue'
|
||||
import PageLocaleRelationsDialog from './PageLocaleRelationsDialog.vue'
|
||||
@@ -368,6 +379,7 @@ const editorStore = useEditorStore()
|
||||
const flagsStore = useFlagsStore()
|
||||
const pageStore = usePageStore()
|
||||
const siteStore = useSiteStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// I18N
|
||||
|
||||
@@ -385,17 +397,6 @@ const state = reactive({
|
||||
showQuickAccess: true
|
||||
})
|
||||
|
||||
const quickaccess = [
|
||||
{ key: 'refCardInfo', icon: 'la:info-circle', label: t('editor.props.info') },
|
||||
{ key: 'refCardPublishState', icon: 'la:power-off', label: t('editor.props.publishState') },
|
||||
{ key: 'refCardRelations', icon: 'la:sun', label: t('editor.props.relations') },
|
||||
{ key: 'refCardScripts', icon: 'la:code', label: t('editor.props.scripts') },
|
||||
{ key: 'refCardSidebar', icon: 'la:ruler-vertical', label: t('editor.props.sidebar') },
|
||||
{ key: 'refCardSocial', icon: 'la:comments', label: t('editor.props.social') },
|
||||
{ key: 'refCardTags', icon: 'la:tags', label: t('editor.props.tags') },
|
||||
{ key: 'refCardVisibility', icon: 'la:eye', label: t('editor.props.visibility') }
|
||||
]
|
||||
|
||||
// REFS
|
||||
|
||||
const iptTitle = ref(null)
|
||||
@@ -403,6 +404,40 @@ const iptPagePassword = ref(null)
|
||||
|
||||
// COMPUTED
|
||||
|
||||
/*
|
||||
Whether this author may write a script or a stylesheet onto THIS page.
|
||||
|
||||
Read off `pagePermissions` rather than through `userStore.can()`: both are page rule permissions,
|
||||
granted per path by a group's rules, and `can()` also answers for the group-wide list -- which is a
|
||||
broader question ("somewhere") than the one these buttons ask ("here"). It is the same list
|
||||
`buildScripts` consults on the way in, an administrator holding all of them.
|
||||
*/
|
||||
const mayWriteScripts = computed(() => userStore.pagePermissions.includes('write:scripts'))
|
||||
const mayWriteStyles = computed(() => userStore.pagePermissions.includes('write:styles'))
|
||||
|
||||
/*
|
||||
The rail of jump links down the side of the panel. A computed rather than a constant because the
|
||||
Scripts section is not always there, and a link to a section that is not rendered is a link that
|
||||
throws -- `jumpToSection` reads the element straight off the document.
|
||||
*/
|
||||
const quickaccess = computed(() =>
|
||||
[
|
||||
{ key: 'refCardInfo', icon: 'la:info-circle', label: t('editor.props.info') },
|
||||
{ key: 'refCardPublishState', icon: 'la:power-off', label: t('editor.props.publishState') },
|
||||
{ key: 'refCardRelations', icon: 'la:sun', label: t('editor.props.relations') },
|
||||
{
|
||||
key: 'refCardScripts',
|
||||
icon: 'la:code',
|
||||
label: t('editor.props.scripts'),
|
||||
shown: mayWriteScripts.value || mayWriteStyles.value
|
||||
},
|
||||
{ key: 'refCardSidebar', icon: 'la:ruler-vertical', label: t('editor.props.sidebar') },
|
||||
{ key: 'refCardSocial', icon: 'la:comments', label: t('editor.props.social') },
|
||||
{ key: 'refCardTags', icon: 'la:tags', label: t('editor.props.tags') },
|
||||
{ key: 'refCardVisibility', icon: 'la:eye', label: t('editor.props.visibility') }
|
||||
].filter((qa) => qa.shown !== false)
|
||||
)
|
||||
|
||||
const publishingRange = computed({
|
||||
get() {
|
||||
return {
|
||||
|
||||
@@ -52,9 +52,10 @@ import UtilCodeEditor from './UtilCodeEditor.vue'
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
/** Which of the three the dialog is editing: `jsLoad`, `jsUnload` or `styles`. */
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'css'
|
||||
default: 'styles'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -102,15 +103,28 @@ const languageLabel = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const contentStoreKey = computed(() => {
|
||||
return 'script' + props.mode.charAt(0).toUpperCase() + props.mode.slice(1)
|
||||
})
|
||||
/*
|
||||
Which store field this dialog is editing. A table rather than a name built from `mode`, because the
|
||||
two do not line up: the CSS mode is called `styles` -- it is the button in the properties panel and
|
||||
the translation key of its label -- while the field it writes is `scriptCss`. Assembled, it spelled
|
||||
`scriptStyles`, a field the store does not have, so the CSS editor opened empty on a page that had
|
||||
CSS and saved into nothing.
|
||||
*/
|
||||
const STORE_KEYS = {
|
||||
jsLoad: 'scriptJsLoad',
|
||||
jsUnload: 'scriptJsUnload',
|
||||
styles: 'scriptCss'
|
||||
}
|
||||
|
||||
const contentStoreKey = computed(() => STORE_KEYS[props.mode])
|
||||
|
||||
// METHODS
|
||||
|
||||
function persist() {
|
||||
// -> `.value`: the computed itself as a key stringifies to `[object Object]`, which is where every
|
||||
// edit made in this dialog used to go
|
||||
pageStore.$patch({
|
||||
[contentStoreKey]: state.content
|
||||
[contentStoreKey.value]: state.content
|
||||
})
|
||||
}
|
||||
|
||||
@@ -129,7 +143,7 @@ function saveAndClose() {
|
||||
// -> No deferred mount: the quarter-second wait was there to give the old editor a laid-out container
|
||||
// to measure itself against, and a textarea needs no such thing
|
||||
onMounted(() => {
|
||||
state.content = pageStore[contentStoreKey.value]
|
||||
state.content = pageStore[contentStoreKey.value] ?? ''
|
||||
// -> The editor is what this dialog is for, so the caret starts there. After the tick that renders
|
||||
// the content above, so focus lands on a field that is already populated.
|
||||
nextTick(() => {
|
||||
|
||||
@@ -21,8 +21,16 @@
|
||||
:class="[dense ? 'size-4' : 'size-5', knobOffset]" />
|
||||
</span>
|
||||
<span
|
||||
class="w-toggle__knob inline-flex items-center justify-center rounded-full transition-transform duration-200"
|
||||
class="w-toggle__knob relative inline-flex items-center justify-center rounded-full"
|
||||
:class="[dense ? 'size-4' : 'size-5', knobOffset]">
|
||||
<!--
|
||||
A second copy of the same glyph, one pixel down-right and under the real one, so the mark
|
||||
keeps an edge against the knob it sits on. Same light source as the rest of the relief.
|
||||
-->
|
||||
<w-icon
|
||||
class="w-toggle__mark-shadow"
|
||||
:name="isOn ? 'mdi:check' : 'mdi:close'"
|
||||
:size="dense ? '11px' : '13px'" />
|
||||
<w-icon
|
||||
class="w-toggle__mark"
|
||||
:name="isOn ? 'mdi:check' : 'mdi:close'"
|
||||
@@ -40,12 +48,17 @@ import { computed } from 'vue'
|
||||
* On/off switch.
|
||||
*
|
||||
* The track is a recessed channel and the knob sits proud of it, lit from the top left; state is
|
||||
* read from where the knob sits plus the mark at its centre -- a tick when on, a cross when off,
|
||||
* drawn in a muted tone so it reads as engraved into the knob rather than printed on it.
|
||||
* read from where the knob sits plus the status tone -- a tick when on, a cross when off.
|
||||
*
|
||||
* A disabled switch drops both its relief and its status colour: no glow, a grey mark, and a flat
|
||||
* track. Dimming alone read as "slightly faded" rather than "not available", and a green tick on a
|
||||
* control nobody can move says the wrong thing twice over.
|
||||
* WHICH part wears that tone depends on the theme, and it is the one thing about this control that
|
||||
* is not just a change of value between the two. On a dark surface the pin itself is the coloured
|
||||
* element, with a white mark on it, which is what gives the switch something bright to read at a
|
||||
* glance. On a light one the pin stays near-white and the MARK carries the colour, because a
|
||||
* saturated pin on a pale page shouts far louder than the same pin does against a dark one.
|
||||
*
|
||||
* A disabled switch drops both its relief and its status colour: no glow, a neutral pin, a grey
|
||||
* mark, and a flat track. Dimming alone read as "slightly faded" rather than "not available", and a
|
||||
* green mark on a control nobody can move says the wrong thing twice over.
|
||||
*
|
||||
* The label's `pt-px` is optical centring, the same compensation WInput makes: Roboto's ascent
|
||||
* exceeds its descent, so a line box centred by geometry renders its glyphs above the middle of
|
||||
@@ -140,6 +153,12 @@ function toggle() {
|
||||
into glare, and the rim has to be *lighter* than the track where on a light surface it is white.
|
||||
*/
|
||||
.w-toggle {
|
||||
/*
|
||||
The status tone, in one place: the glow always takes it, and each theme points either the pin or
|
||||
the mark at it further down. It lives on the button rather than on the three elements that draw
|
||||
it, so they cannot disagree about which state they are showing.
|
||||
*/
|
||||
--w-toggle-status: var(--color-negative);
|
||||
--w-toggle-track: #dfe3ea;
|
||||
--w-toggle-rim: #ffffff;
|
||||
--w-toggle-knob: #fdfdfe;
|
||||
@@ -148,20 +167,38 @@ function toggle() {
|
||||
--w-toggle-highlight: rgb(255 255 255 / 0.95);
|
||||
--w-toggle-cast: rgb(0 0 0 / 0.12);
|
||||
--w-toggle-glow: 0.62;
|
||||
--w-toggle-mark: var(--w-toggle-status);
|
||||
--w-toggle-mark-disabled: #8a8f98;
|
||||
/*
|
||||
A pale pin needs no relief under the mark, so the copy is the pin's own tone and draws nothing.
|
||||
Keeping it painted rather than hidden is what makes the dark theme a change of colour and not a
|
||||
change of markup.
|
||||
*/
|
||||
--w-toggle-mark-shadow: #ffffff;
|
||||
/* The pin is neutral here either way, so disabled changes only the mark */
|
||||
--w-toggle-knob-disabled: var(--w-toggle-knob);
|
||||
}
|
||||
|
||||
.w-toggle[aria-checked='true'] {
|
||||
--w-toggle-status: var(--color-positive);
|
||||
}
|
||||
|
||||
:global(body.body--dark .w-toggle) {
|
||||
--w-toggle-track: #262c38;
|
||||
--w-toggle-rim: #39414f;
|
||||
--w-toggle-knob: #6b7382;
|
||||
--w-toggle-knob: var(--w-toggle-status);
|
||||
--w-toggle-knob-rim: rgb(255 255 255 / 0.1);
|
||||
--w-toggle-shadow: rgb(0 0 0 / 0.6);
|
||||
--w-toggle-highlight: rgb(255 255 255 / 0.07);
|
||||
--w-toggle-cast: rgb(0 0 0 / 0.45);
|
||||
/* Held up a little: the same tone has less to carry against a dark channel than a pale one */
|
||||
--w-toggle-glow: 0.72;
|
||||
--w-toggle-mark: #ffffff;
|
||||
--w-toggle-mark-disabled: #aeb4bf;
|
||||
/* White on the positive tone is a weak pairing, so the mark gets an edge to read against */
|
||||
--w-toggle-mark-shadow: rgb(0 0 0 / 0.3);
|
||||
/* Shed the status colour when the control cannot be moved; this is the pin's old neutral */
|
||||
--w-toggle-knob-disabled: #6b7382;
|
||||
}
|
||||
|
||||
.w-toggle__glow-clip {
|
||||
@@ -183,7 +220,7 @@ function toggle() {
|
||||
step with the knob.
|
||||
*/
|
||||
.w-toggle__glow {
|
||||
background-color: var(--color-negative);
|
||||
background-color: var(--w-toggle-status);
|
||||
opacity: var(--w-toggle-glow);
|
||||
filter: blur(9px);
|
||||
transition:
|
||||
@@ -191,10 +228,6 @@ function toggle() {
|
||||
background-color 0.2s var(--ease-standard);
|
||||
}
|
||||
|
||||
[aria-checked='true'] .w-toggle__glow {
|
||||
background-color: var(--color-positive);
|
||||
}
|
||||
|
||||
.w-toggle__track {
|
||||
background-color: var(--w-toggle-track);
|
||||
box-shadow:
|
||||
@@ -204,30 +237,50 @@ function toggle() {
|
||||
2px 3px 6px var(--w-toggle-cast);
|
||||
}
|
||||
|
||||
/*
|
||||
The transition is declared here rather than with Tailwind's `transition-transform`, because in the
|
||||
dark theme the pin's colour has to travel with its movement. Same duration and easing as the glow,
|
||||
which shares the knob's offset class and so must move in step with it.
|
||||
*/
|
||||
.w-toggle__knob {
|
||||
background-color: var(--w-toggle-knob);
|
||||
box-shadow:
|
||||
0 0 0 1px var(--w-toggle-knob-rim),
|
||||
2px 2px 4px var(--w-toggle-shadow),
|
||||
-2px -2px 4px var(--w-toggle-highlight);
|
||||
transition:
|
||||
translate 0.2s var(--ease-standard),
|
||||
background-color 0.2s var(--ease-standard);
|
||||
}
|
||||
|
||||
/*
|
||||
The mark takes the theme's own positive/negative tones rather than literal green and red. Those
|
||||
two are re-mapped at runtime for colour-vision deficiency (see stores/user.js), which is exactly
|
||||
the case where a green/red pair would otherwise stop distinguishing anything -- and they follow
|
||||
a site's palette for free.
|
||||
The status tones are the theme's own rather than literal green and red. Those two are re-mapped at
|
||||
runtime for colour-vision deficiency (see stores/user.js), which is exactly the case where a
|
||||
green/red pair would otherwise stop distinguishing anything -- and they follow a site's palette
|
||||
for free.
|
||||
|
||||
It stays a confirmation, not the signal: the knob's position is what announces the state, which
|
||||
is what keeps this readable when the two tones are indistinguishable to the viewer.
|
||||
The mark stays a confirmation, not the signal: the knob's position is what announces the state,
|
||||
which is what keeps this readable when the two tones are indistinguishable to the viewer.
|
||||
*/
|
||||
.w-toggle__mark {
|
||||
color: var(--color-negative);
|
||||
position: relative;
|
||||
color: var(--w-toggle-mark);
|
||||
transition: color 0.2s var(--ease-standard);
|
||||
}
|
||||
|
||||
[aria-checked='true'] .w-toggle__mark {
|
||||
color: var(--color-positive);
|
||||
/*
|
||||
The mark's drop shadow. Both copies are positioned, so painting order is document order and the
|
||||
real mark covers this one -- `z-index: -1` would put it behind the knob's own background instead.
|
||||
|
||||
What it draws is the theme's business, and both answers are in the variable blocks above: a pixel
|
||||
of dark under a white mark on a coloured pin, or nothing at all on a pale one.
|
||||
*/
|
||||
.w-toggle__mark-shadow {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
translate: calc(-50% + 1px) calc(-50% + 1px);
|
||||
color: var(--w-toggle-mark-shadow);
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -241,6 +294,7 @@ function toggle() {
|
||||
}
|
||||
|
||||
.w-toggle--disabled .w-toggle__knob {
|
||||
background-color: var(--w-toggle-knob-disabled);
|
||||
box-shadow: 0 0 0 1px var(--w-toggle-knob-rim);
|
||||
}
|
||||
|
||||
@@ -258,6 +312,14 @@ function toggle() {
|
||||
color: var(--w-toggle-mark-disabled);
|
||||
}
|
||||
|
||||
/*
|
||||
A flat control has nothing for a shadow to be cast onto, and the grey mark is what says the switch
|
||||
is out of reach -- giving it relief the operable one has would undo that.
|
||||
*/
|
||||
.w-toggle--disabled .w-toggle__mark-shadow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.w-toggle__knob,
|
||||
.w-toggle__mark,
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
import { computed, nextTick, onScopeDispose, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { usePageStore } from '@/stores/page'
|
||||
|
||||
/**
|
||||
* The style element carrying the current page's CSS. One per document: only one page is on screen at
|
||||
* a time, so this is replaced rather than added to, and a page without CSS has none at all.
|
||||
*/
|
||||
const STYLE_ID = 'page-styles'
|
||||
|
||||
/**
|
||||
* Apply the page's CSS, replacing whatever the previous page had.
|
||||
*
|
||||
* Appended to the head, so these rules come after the app's own stylesheets and win a tie on order --
|
||||
* which is what a per-page override is for. It is not scoped to the article: the field is documented
|
||||
* as "CSS rules to add to the page", and a page that wants to restyle the header or the sidebar for
|
||||
* its own visit is the reason it exists.
|
||||
*
|
||||
* @param {string} css
|
||||
*/
|
||||
function applyStyles(css) {
|
||||
const existing = document.getElementById(STYLE_ID)
|
||||
if (!css?.trim()) {
|
||||
existing?.remove()
|
||||
return
|
||||
}
|
||||
const el = existing ?? document.createElement('style')
|
||||
el.id = STYLE_ID
|
||||
el.textContent = css
|
||||
if (!existing) {
|
||||
document.head.appendChild(el)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one of the page's scripts.
|
||||
*
|
||||
* A `<script>` element rather than `new Function(code)()`, for three reasons. It runs the code as a
|
||||
* classic script at top level, so `var`, a function declaration or a `const` the author expects to
|
||||
* find from an inline handler in the content lands where they expect it. It throws nowhere this
|
||||
* caller can see -- a broken page script is reported as an uncaught error on `window`, the way a
|
||||
* broken script in a document is, instead of taking the page view's own code down with it. And under
|
||||
* a Content-Security-Policy it needs `script-src 'unsafe-inline'` rather than `'unsafe-eval'`, which
|
||||
* is the lesser of the two to have to allow.
|
||||
*
|
||||
* The element is removed the moment it has run: insertion is what executes it and it is inert from
|
||||
* then on, so leaving it behind would only pile up one dead element per navigation.
|
||||
*
|
||||
* @param {string} code
|
||||
*/
|
||||
function execute(code) {
|
||||
if (!code?.trim()) {
|
||||
return
|
||||
}
|
||||
const el = document.createElement('script')
|
||||
el.type = 'text/javascript'
|
||||
el.textContent = code
|
||||
document.body.appendChild(el)
|
||||
el.remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* The page's own CSS and Javascript, on the page the reader is looking at.
|
||||
*
|
||||
* The three fields the properties panel writes -- `scriptCss`, `scriptJsLoad`, `scriptJsUnload` --
|
||||
* are stored per page and, until this existed, were never applied to anything: they came down with
|
||||
* every page and nothing read them. This is where they take effect, in the page view, because that is
|
||||
* where the page's content actually is.
|
||||
*
|
||||
* What runs when:
|
||||
*
|
||||
* - **CSS** is in force for as long as the page is on screen, the editor included -- an author
|
||||
* writing rules for a page wants to see them applied to it, and the dialog patches the store as it
|
||||
* is saved, so the change shows immediately.
|
||||
* - **`jsLoad`** runs once the page's content is in the document, which is the tick after the store
|
||||
* changes: the element the script goes looking for has to exist by then.
|
||||
* - **`jsUnload`** runs before that content is destroyed -- walking to another page, opening the
|
||||
* editor over it, or leaving the page view altogether. The code that runs is the one the page on
|
||||
* screen came with, captured when its load script ran, since by the time a navigation gets here the
|
||||
* store already holds the page arriving.
|
||||
*
|
||||
* What does NOT run either of them is the script itself changing. The properties panel writes these
|
||||
* fields into the page store as they are edited -- the panel opens over a page being read as well as
|
||||
* over one being written -- so a watcher on the code would run an author's Javascript the moment they
|
||||
* pressed Save in the editor dialog, against the page behind it and before it was saved anywhere.
|
||||
* Arriving at the page is the event; the code is read when it happens.
|
||||
*
|
||||
* A page nobody is reading runs nothing: not while the editor is over it -- what is on screen there is
|
||||
* a preview being typed into rather than the page -- not on a path with no page, and not on a locked
|
||||
* one, whose body the server never sent.
|
||||
*
|
||||
* A full document unload (a reload, a closed tab) is deliberately not one of the cases: the document
|
||||
* and everything the script did to it are going away regardless, and a `beforeunload` handler that
|
||||
* runs a page's arbitrary Javascript is a good way to make leaving the wiki slow.
|
||||
*/
|
||||
export function usePageScripts() {
|
||||
const editorStore = useEditorStore()
|
||||
const pageStore = usePageStore()
|
||||
const route = useRoute()
|
||||
|
||||
/** The unload script of the page currently on screen, kept from when its load script ran. */
|
||||
let liveUnload = ''
|
||||
|
||||
/** Whether there is a page on screen at all -- one that arrived, with a body to style or script. */
|
||||
const hasPage = computed(
|
||||
() => Boolean(pageStore.id) && !pageStore.notFound && !pageStore.isLocked
|
||||
)
|
||||
|
||||
watch(() => (hasPage.value ? pageStore.scriptCss : ''), applyStyles, { immediate: true })
|
||||
|
||||
/**
|
||||
* Whether the page on screen is being written rather than read.
|
||||
*
|
||||
* Two answers, because neither covers the other. `isActive` is the editor being opened over a page
|
||||
* from the page's own URL, which is how the Edit button does it -- the route never moves. The route
|
||||
* test is for arriving at `/_edit/<page>` or `/_create` directly, where the editor is what the
|
||||
* reader asked for but is not open yet: the page is fetched into the store first and the editor's
|
||||
* own configuration after that, and in the gap between the two nothing but the URL says that this
|
||||
* page is not simply being read. That gap is a round trip wide, so it is not one a later re-check
|
||||
* can close -- the script would have run long before.
|
||||
*/
|
||||
const isEditing = computed(
|
||||
() => editorStore.isActive || /^\/_(edit|create)(\/|$)/.test(route.path)
|
||||
)
|
||||
|
||||
/**
|
||||
* The page whose Javascript is live: its id while it is being read, and null while it is not -- no
|
||||
* page, a locked one, or the editor over it.
|
||||
*/
|
||||
const liveId = computed(() => (hasPage.value && !isEditing.value ? pageStore.id : null))
|
||||
|
||||
/*
|
||||
So the callback fires on arriving at a page, on leaving one, and on the editor opening and closing
|
||||
over one, and on nothing else.
|
||||
|
||||
A `pre` watcher (the default), which is the whole reason the unload script still has a page to act
|
||||
on: it runs before the components re-render, so the store already describes the page arriving
|
||||
while the document still holds the one being left. (The URL has moved on by then too -- the router
|
||||
navigates first -- so a script reading `location` during unload sees where the reader is going.)
|
||||
*/
|
||||
watch(
|
||||
liveId,
|
||||
(next) => {
|
||||
runUnload()
|
||||
if (!next) {
|
||||
return
|
||||
}
|
||||
// -> The tick that puts the new page's content in the document; the script runs against it
|
||||
nextTick(() => {
|
||||
/*
|
||||
Asked again rather than trusted from the watcher: a tick is long enough for the reader to
|
||||
have gone somewhere else, and this script is only ever meant to run against the page that is
|
||||
actually on screen when it does.
|
||||
*/
|
||||
if (liveId.value !== next) {
|
||||
return
|
||||
}
|
||||
liveUnload = pageStore.scriptJsUnload
|
||||
execute(pageStore.scriptJsLoad)
|
||||
})
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
/** Run the on-screen page's unload script, once. */
|
||||
function runUnload() {
|
||||
const code = liveUnload
|
||||
liveUnload = ''
|
||||
execute(code)
|
||||
}
|
||||
|
||||
// -> The page view itself going away: the search screen, the admin area, a profile page
|
||||
onScopeDispose(() => {
|
||||
runUnload()
|
||||
applyStyles('')
|
||||
})
|
||||
}
|
||||
@@ -354,6 +354,7 @@ import { useDark } from '@/composables/dark'
|
||||
import { dialog } from '@/composables/dialog'
|
||||
import { useMeta } from '@/composables/meta'
|
||||
import { useMinWidth } from '@/composables/screen'
|
||||
import { usePageScripts } from '@/composables/pageScripts'
|
||||
import { notify } from '@/composables/notify'
|
||||
import { withViewTransition } from '@/composables/viewTransition'
|
||||
import { loading } from '@/composables/loading'
|
||||
@@ -421,6 +422,12 @@ const { t } = useI18n()
|
||||
|
||||
const dark = useDark()
|
||||
|
||||
/*
|
||||
The page's own CSS and Javascript, from the properties panel. Nothing to hold onto -- it follows the
|
||||
page store on its own, for as long as this view is mounted.
|
||||
*/
|
||||
usePageScripts()
|
||||
|
||||
// META
|
||||
|
||||
/*
|
||||
|
||||
Reference in new issue
Block a user