All checks were successful
Build and Deploy Nuxt / build (push) Successful in 29s
896 lines
34 KiB
Vue
896 lines
34 KiB
Vue
<script setup lang="ts">
|
|
import type { ArticleMeta } from './ArticleCard.vue'
|
|
|
|
const props = defineProps<{
|
|
mode: 'create' | 'edit'
|
|
article?: ArticleMeta & { content?: string }
|
|
/* Translating: the version being translated. The form opens with its text
|
|
and publishes a new language version of the same article. */
|
|
translateFrom?: ArticleMeta & { content?: string }
|
|
/* Languages the article being translated already exists in */
|
|
takenLanguages?: string[]
|
|
}>()
|
|
|
|
const api = `${useRuntimeConfig().public.apiBase}/api`
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
/* Writing is the one thing on this page that takes an account: the save carries
|
|
the session token, and a save the API refuses is the editor's own signal that
|
|
the session has gone while this draft was open. A member's save is not a
|
|
write at all — neither a change to a page nor a page the wiki does not have
|
|
yet: the API keeps either as a proposal for an admin (models/EditProposal.js
|
|
on the backend); only an admin's goes straight onto the page. */
|
|
const { authHeaders, isAdmin } = useAuth()
|
|
const { show: openSignIn } = useSignInPopup()
|
|
const { t } = useI18n()
|
|
|
|
/* saving without the admin role: every save is a request an admin answers, and
|
|
the editor says so rather than pretending to publish */
|
|
const memberWrite = computed(() => !isAdmin.value)
|
|
/* which request the editor is holding: a change to an existing page, or a new
|
|
one — the words it says the member differ, and a new page has nothing to view
|
|
until an admin approves it */
|
|
const creating = computed(() => props.mode === 'create')
|
|
|
|
const original = props.translateFrom ?? null
|
|
/* the version group the new document joins, shared by every translation */
|
|
const baseSlug = original ? original.baseSlug || original.slug : ''
|
|
|
|
/* an edit is always an article — every language version of it answers to the
|
|
same identity, which is what its reader and editor addresses speak */
|
|
const isVersioned = computed(() => props.mode === 'edit' && !!props.article)
|
|
const identity = props.article ? props.article.baseSlug || props.article.slug : ''
|
|
|
|
/* versions under edit -------------------------------------------------
|
|
Editing an article happens one language at a time: each version keeps its
|
|
own draft beside the others, so typing French does not overwrite English —
|
|
switching turns the page, it does not clear it. */
|
|
interface VersionDraft {
|
|
language: string
|
|
/** saved document's slug; null for a version not written yet */
|
|
slug: string | null
|
|
title: string
|
|
tagsText: string
|
|
content: string
|
|
/** the text as it was loaded or last saved — what "dirty" compares against */
|
|
saved: { title: string; tagsText: string; content: string }
|
|
/** whether the version's full text has arrived from the API */
|
|
loaded: boolean
|
|
}
|
|
|
|
const drafts = ref<Record<string, VersionDraft>>({})
|
|
|
|
function seedDraft(language: string, slug: string | null, doc?: ArticleMeta & { content?: string }): VersionDraft {
|
|
return {
|
|
language,
|
|
slug,
|
|
title: doc?.title ?? '',
|
|
tagsText: (doc?.tags ?? []).join(', '),
|
|
content: doc?.content ?? '',
|
|
saved: {
|
|
title: doc?.title ?? '',
|
|
tagsText: (doc?.tags ?? []).join(', '),
|
|
content: doc?.content ?? '',
|
|
},
|
|
loaded: !!doc,
|
|
}
|
|
}
|
|
|
|
if (isVersioned.value && props.article) {
|
|
const doc = props.article
|
|
const list = doc.versions?.length
|
|
? doc.versions
|
|
: [{ language: doc.language ?? 'en', slug: doc.slug, title: doc.title }]
|
|
for (const v of list) {
|
|
/* the document already in hand needs no fetch; the rest arrive on demand
|
|
with only their titles known up front — enough to fill the field while
|
|
the text comes over */
|
|
const draft = seedDraft(v.language, v.slug, v.slug === doc.slug ? doc : undefined)
|
|
if (!draft.loaded && v.title) draft.title = v.title
|
|
drafts.value[v.language] = draft
|
|
}
|
|
}
|
|
|
|
/* the language this editor is showing — its address is ?lang= on the route */
|
|
const initialLang = String(route.query.lang ?? '').trim().toLowerCase()
|
|
const activeLang = ref(
|
|
drafts.value[initialLang]?.language ?? props.article?.language ?? 'en'
|
|
)
|
|
|
|
/* one draft is in view and edited through these; the rest wait in `drafts` */
|
|
const title = ref(activeDraft()?.title ?? original?.title ?? '')
|
|
const tagsText = ref(activeDraft()?.tagsText ?? (original?.tags ?? []).join(', '))
|
|
const content = ref(activeDraft()?.content ?? original?.content ?? '')
|
|
/* the language a fresh (non-versioned) article is written in — the reader's
|
|
language menu asks for one on the route (/new?translate=x&lang=de): start
|
|
there, unless the article is already written in it */
|
|
const requestedLang = String(route.query.lang ?? '').trim().toLowerCase()
|
|
const preselected =
|
|
LANGUAGES.some((l) => l.code === requestedLang) &&
|
|
!(props.takenLanguages ?? []).includes(requestedLang)
|
|
? requestedLang
|
|
: ''
|
|
const language = ref(
|
|
props.article?.language?.trim().toLowerCase() ||
|
|
preselected ||
|
|
nextFreeLanguage(props.takenLanguages ?? [])
|
|
)
|
|
|
|
function activeDraft(): VersionDraft | undefined {
|
|
return drafts.value[activeLang.value]
|
|
}
|
|
|
|
const versionList = computed(() => Object.values(drafts.value))
|
|
const freeLanguages = computed(() => LANGUAGES.filter((l) => !drafts.value[l.code]))
|
|
|
|
function draftCurrent(d: VersionDraft) {
|
|
return d.language === activeLang.value
|
|
? { title: title.value, tagsText: tagsText.value, content: content.value }
|
|
: { title: d.title, tagsText: d.tagsText, content: d.content }
|
|
}
|
|
|
|
function draftChanged(d: VersionDraft) {
|
|
if (!d.loaded) return false
|
|
return JSON.stringify(draftCurrent(d)) !== JSON.stringify(d.saved)
|
|
}
|
|
|
|
const dirtyCount = computed(
|
|
() => (isVersioned.value ? versionList.value.filter(draftChanged).length : singleDirty.value ? 1 : 0)
|
|
)
|
|
|
|
/* fetch a version's text the first time the editor turns to it */
|
|
async function hydrate(d: VersionDraft) {
|
|
if (!d.slug) return
|
|
/* remember what the fields held on arrival: if anything was typed while the
|
|
fetch was on its way, the typing wins over what comes back */
|
|
const untouched = { title: title.value, tagsText: tagsText.value, content: content.value }
|
|
try {
|
|
const doc = await $fetch<ArticleMeta & { content?: string }>(
|
|
`${api}/articles/slug/${encodeURIComponent(d.slug)}`
|
|
)
|
|
d.title = doc.title ?? ''
|
|
d.tagsText = (doc.tags ?? []).join(', ')
|
|
d.content = doc.content ?? ''
|
|
d.saved = { title: d.title, tagsText: d.tagsText, content: d.content }
|
|
d.loaded = true
|
|
/* if the editor still rests on this version, and nothing was typed into
|
|
it since, show what arrived */
|
|
if (
|
|
d.language === activeLang.value &&
|
|
JSON.stringify(untouched) ===
|
|
JSON.stringify({ title: title.value, tagsText: tagsText.value, content: content.value })
|
|
) {
|
|
title.value = d.title
|
|
tagsText.value = d.tagsText
|
|
content.value = d.content
|
|
}
|
|
} catch {
|
|
/* an unreadable version stays a blank page; saving will say why it failed */
|
|
}
|
|
}
|
|
|
|
function putDraftFields(d: VersionDraft | undefined) {
|
|
if (!d) return
|
|
title.value = d.title
|
|
tagsText.value = d.tagsText
|
|
content.value = d.content
|
|
}
|
|
|
|
function stashActive() {
|
|
const d = activeDraft()
|
|
if (!d || !d.loaded) return
|
|
d.title = title.value
|
|
d.tagsText = tagsText.value
|
|
d.content = content.value
|
|
}
|
|
|
|
async function switchLang(lang: string) {
|
|
if (lang === activeLang.value) return
|
|
stashActive()
|
|
activeLang.value = lang
|
|
const d = drafts.value[lang]
|
|
putDraftFields(d)
|
|
if (d && !d.loaded) await hydrate(d)
|
|
void router.replace({ query: { ...route.query, lang } })
|
|
}
|
|
|
|
/* the language dropdown beside the chips -------------------------------- */
|
|
const addOpen = ref(false)
|
|
const addWrapEl = ref<HTMLElement | null>(null)
|
|
|
|
function onDocClick(e: MouseEvent) {
|
|
if (addOpen.value && !addWrapEl.value?.contains(e.target as Node)) addOpen.value = false
|
|
}
|
|
onMounted(() => document.addEventListener('click', onDocClick))
|
|
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
|
|
|
|
function addLanguage(code: string) {
|
|
addOpen.value = false
|
|
stashActive()
|
|
/* a fresh version starts empty under the hood and carries the article's
|
|
tags across — they describe the topic, not the language */
|
|
drafts.value[code] = seedDraft(code, null, {
|
|
_id: '',
|
|
slug: '',
|
|
title: '',
|
|
tags: tagsText.value
|
|
.split(',')
|
|
.map((t) => t.trim().toLowerCase())
|
|
.filter(Boolean),
|
|
content: '',
|
|
} as ArticleMeta)
|
|
activeLang.value = code
|
|
title.value = ''
|
|
tagsText.value = drafts.value[code]!.tagsText
|
|
content.value = ''
|
|
void router.replace({ query: { ...route.query, lang: code } })
|
|
}
|
|
|
|
/* the version in view is fetched on demand, even on first arrival (?lang=fr) */
|
|
onMounted(() => {
|
|
const d = activeDraft()
|
|
if (isVersioned.value && d && !d.loaded) void hydrate(d)
|
|
})
|
|
|
|
const languages = computed(() =>
|
|
LANGUAGES.map((l) => ({
|
|
...l,
|
|
taken: !!original && (props.takenLanguages ?? []).includes(l.code),
|
|
}))
|
|
)
|
|
|
|
function slugifyText(text: string): string {
|
|
return text
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^\w\s-]/g, '')
|
|
.replace(/[\s_]+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
}
|
|
|
|
/* the address the article in hand lives at — its identity with the language of
|
|
the version in view in front. The title plays no part: a translation's
|
|
address is the article's identity whatever it is titled, and the canonical
|
|
version's identity *is* its slug, so guessing from the title would send a
|
|
retitle off to a new address (and onto a twin article's, when a name was
|
|
taken and the slug carries a -2). Only an article the wiki has not been
|
|
given yet can be previewed from its title. */
|
|
const slugPreview = computed(() => {
|
|
if (isVersioned.value) {
|
|
const lang = activeDraft()?.language ?? props.article?.language
|
|
return versionHref(identity, lang)
|
|
}
|
|
if (original) return versionHref(baseSlug, language.value)
|
|
return versionHref(slugifyText(title.value) || '…', language.value)
|
|
})
|
|
|
|
const savedSlug = props.article?.slug ?? ''
|
|
|
|
/* dirty tracking -------------------------------------------------
|
|
A versioned edit is dirty when any version's text differs from what it was
|
|
loaded or saved as; the plain create form compares against what it opened
|
|
with. */
|
|
interface FormState {
|
|
title: string
|
|
tagsText: string
|
|
content: string
|
|
}
|
|
|
|
function currentState(): FormState {
|
|
return { title: title.value, tagsText: tagsText.value, content: content.value }
|
|
}
|
|
|
|
const savedState = ref<FormState>(currentState())
|
|
const singleDirty = computed(
|
|
() => !isVersioned.value && JSON.stringify(currentState()) !== JSON.stringify(savedState.value)
|
|
)
|
|
const dirty = computed(() =>
|
|
isVersioned.value ? versionList.value.some(draftChanged) : singleDirty.value
|
|
)
|
|
|
|
function markSaved() {
|
|
savedState.value = currentState()
|
|
for (const d of versionList.value) if (draftChanged(d)) d.saved = draftCurrent(d)
|
|
}
|
|
|
|
onBeforeRouteLeave(async () => {
|
|
if (!dirty.value) return true
|
|
return window.confirm(t('editor.leaveGuard'))
|
|
})
|
|
|
|
/* preview ---------------------------------------------------------- */
|
|
/* The textarea's seed stays partly in code: the LaTeX snippet cannot live
|
|
verbatim in a message catalog (`{` would read as a parameter), so only the
|
|
words are translated and the maths rides along here. */
|
|
const SEED_MATH = '$$ \\zeta(s) = \\sum_{n=1}^{\\infty} \\frac{1}{n^s} $$'
|
|
const contentPlaceholder = computed(
|
|
() => `# ${t('editor.seedHeading')}\n\n${t('editor.seedTerms')}\n\n${SEED_MATH}`
|
|
)
|
|
|
|
type Mode = 'write' | 'split' | 'preview'
|
|
const mode = ref<Mode>('split')
|
|
const previewContent = computed(() => content.value)
|
|
|
|
/* fullscreen -------------------------------------------------------- */
|
|
const fullscreen = ref(false)
|
|
const editorBodyEl = ref<HTMLElement | null>(null)
|
|
let fullscreenAnim: Animation | null = null
|
|
|
|
/* FLIP the toggle: measure where the editor currently sits, flip the
|
|
class (static box -> fixed full-screen box), then animate the old
|
|
rect into the new one so the editor grows into the screen edges —
|
|
or shrinks back to its place — instead of snapping. */
|
|
async function toggleFullscreen() {
|
|
const el = editorBodyEl.value
|
|
if (!el) return
|
|
|
|
const first = el.getBoundingClientRect() // current visual rect, even mid-animation
|
|
fullscreenAnim?.cancel()
|
|
|
|
fullscreen.value = !fullscreen.value
|
|
await nextTick()
|
|
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
|
const last = el.getBoundingClientRect()
|
|
|
|
const entering = fullscreen.value
|
|
fullscreenAnim = el.animate(
|
|
[
|
|
{
|
|
transformOrigin: 'top left',
|
|
transform: `translate(${first.left - last.left}px, ${first.top - last.top}px) scale(${first.width / last.width}, ${first.height / last.height})`,
|
|
borderRadius: entering ? '15px' : '0px',
|
|
},
|
|
{ transformOrigin: 'top left', transform: 'none', borderRadius: entering ? '0px' : '15px' },
|
|
],
|
|
{ duration: 300, easing: 'cubic-bezier(0.32, 0.72, 0, 1)' }
|
|
)
|
|
fullscreenAnim.onfinish = () => {
|
|
fullscreenAnim = null
|
|
}
|
|
}
|
|
|
|
/* cheatsheet --------------------------------------------------------- */
|
|
/* Popups don't stack in this app: SearchModal and SettingsModal hide
|
|
each other on open. Search, settings and the profile can also be
|
|
opened from anywhere (header, shortcuts) while the editor shows, so
|
|
the cheatsheet steps aside whenever one of them opens. */
|
|
const { open: searchOpen } = useSearch()
|
|
const { open: settingsOpen } = useSettings()
|
|
const { open: profileOpen } = useProfileMenu()
|
|
|
|
const cheatsheet = ref(false)
|
|
|
|
watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) => {
|
|
if (search || settings || profile) cheatsheet.value = false
|
|
})
|
|
|
|
// keep the page behind from scrolling while the editor fills the screen
|
|
watch(fullscreen, (on) => {
|
|
document.body.style.overflow = on ? 'hidden' : ''
|
|
})
|
|
onBeforeUnmount(() => {
|
|
document.body.style.overflow = ''
|
|
})
|
|
|
|
/* submit ----------------------------------------------------------- */
|
|
const saving = ref(false)
|
|
const errorMsg = ref('')
|
|
/* a member's save that the API kept as a proposal: a popup confirms the request
|
|
and dismisses the editor with it — the sent draft is not left on screen to be
|
|
submitted twice. The next edit of the same thing revises the waiting request
|
|
rather than filing a second one, which is what `sentProposalId` carries. */
|
|
const sentOpen = ref(false)
|
|
/* a filed create answers with the request's id: the same draft saved again
|
|
revises that request, even once its title — and so the address it asks for —
|
|
has moved on since */
|
|
const sentProposalId = ref('')
|
|
|
|
/* where leaving a sent request leads: the very version that was sent, read on
|
|
the reader's side of the wiki — the text an admin has not touched yet.
|
|
Nothing to read for a page the wiki has not been given: a create lands on
|
|
the translation it joins, or on the search page. */
|
|
const sentHref = computed(() => {
|
|
if (isVersioned.value) {
|
|
const d = activeDraft()
|
|
if (!d?.slug && !savedSlug) return ''
|
|
return versionHref(identity, d?.language ?? props.article?.language)
|
|
}
|
|
return savedSlug ? versionHref(savedSlug, props.article?.language) : ''
|
|
})
|
|
|
|
function openSentPopup() {
|
|
cheatsheet.value = false // popups don't stack in this app
|
|
sentOpen.value = true
|
|
}
|
|
|
|
/* the popup is the editor's last word on a filed save: dismissing it — button,
|
|
esc or backdrop — is what leaves, taking the reader back to what was sent */
|
|
async function closeSentPopup() {
|
|
sentOpen.value = false
|
|
const target = sentHref.value || (original ? versionHref(baseSlug, original.language) : '')
|
|
await router.push(target || '/')
|
|
}
|
|
|
|
function parseTags(text: string): string[] {
|
|
return text
|
|
.split(',')
|
|
.map((t) => t.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
/* the answer as `$fetch` gives it, status included: a 202 means the API filed
|
|
the change as a proposal rather than writing it, and the request it filed
|
|
comes back along with it so this draft can be sent to the same one again */
|
|
async function request(body: Record<string, unknown>, method: 'POST' | 'PUT', url: string) {
|
|
const res = await $fetch.raw<ArticleMeta & { proposal?: { _id?: string } }>(url, {
|
|
method,
|
|
body,
|
|
headers: authHeaders(),
|
|
})
|
|
return { article: res._data, status: res.status, proposalId: res._data?.proposal?._id ?? '' }
|
|
}
|
|
|
|
async function save() {
|
|
errorMsg.value = ''
|
|
/* nothing to send while the "request sent" popup is up: this draft already
|
|
is with the admins, whatever Ctrl+S might otherwise re-submit */
|
|
if (saving.value || sentOpen.value) return
|
|
saving.value = true
|
|
try {
|
|
if (isVersioned.value) {
|
|
/* every language version edited since the last save goes up together;
|
|
brand-new ones join the article through its identity */
|
|
const pendingDrafts = versionList.value.filter(draftChanged)
|
|
if (!pendingDrafts.length) {
|
|
/* nothing to send: leave for the page this version is actually read at
|
|
(the identity and the language say where — no guessing from titles) */
|
|
await router.push(versionHref(identity, activeLang.value))
|
|
return
|
|
}
|
|
const missing = pendingDrafts.find((d) => !draftCurrent(d).title.trim())
|
|
if (missing) {
|
|
errorMsg.value = t('editor.versionNeedsTitle', { name: languageName(missing.language) })
|
|
void switchLang(missing.language)
|
|
return
|
|
}
|
|
let savedActive: ArticleMeta | null = null
|
|
let lastSaved: ArticleMeta | null = null
|
|
let proposalSent = false
|
|
for (const d of pendingDrafts) {
|
|
const cur = draftCurrent(d)
|
|
const body = {
|
|
title: cur.title.trim(),
|
|
content: cur.content,
|
|
tags: parseTags(cur.tagsText),
|
|
/* a version written here joins the article it belongs to */
|
|
...(d.slug ? {} : { baseSlug: identity, language: d.language }),
|
|
}
|
|
/* an existing version answers with the write — or, for a member, with
|
|
the 202 of a proposal filed; a brand-new one is always published */
|
|
const res = d.slug
|
|
? await request(body, 'PUT', `${api}/articles/slug/${encodeURIComponent(d.slug)}`)
|
|
: await request(body, 'POST', `${api}/articles`)
|
|
if (res.status === 202) {
|
|
proposalSent = true
|
|
d.saved = cur // the draft is filed — with the admins now, not dirty here
|
|
continue
|
|
}
|
|
const saved = res.article
|
|
// a retitle moved the document; the answer carries where it lives now
|
|
d.slug = saved!.slug
|
|
d.saved = cur
|
|
lastSaved = saved
|
|
if (d.language === activeLang.value) savedActive = saved
|
|
}
|
|
/* a member's edits are with the admins now: the popup says so where a
|
|
published save would have navigated, and closing it ends the editor */
|
|
if (proposalSent) {
|
|
openSentPopup()
|
|
return
|
|
}
|
|
/* leave on the page that was in view — the answer itself carries where
|
|
it lives now, so a canonical retitle follows it instead of stranding */
|
|
const target = savedActive ?? lastSaved
|
|
await router.push(target ? versionHref(identityOf(target), target.language) : '/')
|
|
return
|
|
}
|
|
|
|
if (!title.value.trim()) {
|
|
errorMsg.value = t('editor.titleRequired')
|
|
return
|
|
}
|
|
const body = {
|
|
title: title.value.trim(),
|
|
content: content.value,
|
|
language: language.value,
|
|
/* a translation joins the article it translates rather than standing alone */
|
|
...(original ? { baseSlug } : {}),
|
|
tags: parseTags(tagsText.value),
|
|
/* a create the API already filed: saving the draft again revises that
|
|
request, whichever address it asked for when it was first sent */
|
|
...(creating.value && sentProposalId.value ? { proposalId: sentProposalId.value } : {}),
|
|
}
|
|
const url = creating.value
|
|
? `${api}/articles`
|
|
: `${api}/articles/slug/${encodeURIComponent(savedSlug)}`
|
|
const { article: saved, status, proposalId } = await request(
|
|
body,
|
|
creating.value ? 'POST' : 'PUT',
|
|
url
|
|
)
|
|
if (status === 202) {
|
|
markSaved() // the draft is filed — the leave guard stays quiet on the way out
|
|
if (proposalId) sentProposalId.value = proposalId
|
|
openSentPopup()
|
|
return
|
|
}
|
|
// prevent the leave-guard from firing on our own navigation
|
|
markSaved()
|
|
await router.push(versionHref(identityOf(saved!), saved!.language))
|
|
} catch (err: any) {
|
|
/* A session the API won't take any more. Signing out here would unmount this
|
|
very form — and the draft in it — so instead the sign-in popup opens over
|
|
the editor: whatever is written stays put, and signing back in replaces the
|
|
dead token so the next save goes through. */
|
|
if (err?.status === 401 || err?.statusCode === 401) {
|
|
errorMsg.value = ''
|
|
openSignIn({
|
|
notice: err?.data?.message || t('editor.sessionExpired'),
|
|
})
|
|
return
|
|
}
|
|
errorMsg.value = err?.data?.message || t('editor.saveFailed')
|
|
} finally {
|
|
saving.value = false
|
|
}
|
|
}
|
|
|
|
/* discard ---------------------------------------------------------- */
|
|
/* The destructive step lives behind the same kind of popup as settings and
|
|
the cheatsheet, not a browser alert. */
|
|
const discardOpen = ref(false)
|
|
const discardButtonEl = ref<HTMLButtonElement | null>(null)
|
|
|
|
const discardMessage = computed(() =>
|
|
props.mode === 'create'
|
|
? t('editor.discardNew')
|
|
: dirtyCount.value > 1
|
|
? t('editor.discardVersions', { n: dirtyCount.value })
|
|
: t('editor.discardOne', {
|
|
title: activeDraft()?.saved.title || savedState.value.title || t('editor.thisArticle'),
|
|
})
|
|
)
|
|
|
|
function askDiscard() {
|
|
cheatsheet.value = false // popups don't stack in this app
|
|
discardOpen.value = true
|
|
}
|
|
|
|
function closeDiscardPrompt() {
|
|
discardOpen.value = false
|
|
discardButtonEl.value?.focus() // the popup it replaces was removed from the page
|
|
}
|
|
|
|
/* Throw away everything typed since the last save and leave the editor — in
|
|
every version, and versions added since the last save come off entirely.
|
|
The baseline is what the form opened with, restoring which keeps our own
|
|
leave-guard quiet; the way out leads back to the article or the search page. */
|
|
async function discard() {
|
|
discardOpen.value = false
|
|
if (isVersioned.value) {
|
|
for (const [lang, d] of Object.entries(drafts.value)) {
|
|
if (!d.slug) delete drafts.value[lang] // a version never saved: gone whole
|
|
else putDraftFieldsBack(d) // the rest return to their saved text
|
|
}
|
|
const back = activeDraft() ?? props.article
|
|
if (activeDraft()) putDraftFields(activeDraft()) // the fields follow the version in view
|
|
errorMsg.value = ''
|
|
const target = back ? versionHref(identity, back.language) : '/'
|
|
await router.push(target)
|
|
return
|
|
}
|
|
const base = savedState.value
|
|
title.value = base.title
|
|
tagsText.value = base.tagsText
|
|
content.value = base.content
|
|
errorMsg.value = ''
|
|
await router.push(
|
|
props.mode === 'create'
|
|
? original
|
|
? versionHref(baseSlug, original.language)
|
|
: '/'
|
|
: versionHref(savedSlug, props.article?.language)
|
|
)
|
|
}
|
|
|
|
function putDraftFieldsBack(d: VersionDraft) {
|
|
// reset a draft's working copy to its saved text (the fields follow if active)
|
|
d.title = d.saved.title
|
|
d.tagsText = d.saved.tagsText
|
|
d.content = d.saved.content
|
|
}
|
|
|
|
/* Tab inserts spaces in the textarea ------------------------------- */
|
|
function onTabKeydown(e: KeyboardEvent) {
|
|
const el = e.target as HTMLTextAreaElement
|
|
if (e.key !== 'Tab') return
|
|
e.preventDefault()
|
|
const { selectionStart: s, selectionEnd: end } = el
|
|
content.value = content.value.slice(0, s) + ' ' + content.value.slice(end)
|
|
nextTick(() => {
|
|
el.selectionStart = el.selectionEnd = s + 2
|
|
})
|
|
}
|
|
|
|
// Ctrl/Cmd+S saves; Esc closes the topmost popup first, then leaves fullscreen
|
|
function onGlobalKeydown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') {
|
|
if (sentOpen.value) {
|
|
void closeSentPopup() // dismisses the popup and leaves the editor
|
|
return
|
|
}
|
|
if (addOpen.value) {
|
|
addOpen.value = false
|
|
return
|
|
}
|
|
if (discardOpen.value) {
|
|
closeDiscardPrompt()
|
|
return
|
|
}
|
|
if (cheatsheet.value) {
|
|
cheatsheet.value = false
|
|
return
|
|
}
|
|
if (fullscreen.value) {
|
|
void toggleFullscreen()
|
|
return
|
|
}
|
|
}
|
|
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
|
e.preventDefault()
|
|
if (!saving.value) save()
|
|
}
|
|
}
|
|
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
|
|
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
|
</script>
|
|
|
|
<template>
|
|
<form class="editor-page" @submit.prevent="save">
|
|
<div class="editor-head">
|
|
<h1>{{ creating ? (original ? t('editor.translateTitle') : t('editor.newTitle')) : t('editor.editTitle') }}</h1>
|
|
<div class="editor-head-actions">
|
|
<button
|
|
ref="discardButtonEl"
|
|
type="button"
|
|
class="btn btn-ghost"
|
|
:disabled="!dirty"
|
|
:title="t('editor.discardHint')"
|
|
@click="askDiscard"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
|
<path d="M3 3v5h5" />
|
|
</svg>
|
|
{{ t('editor.discard') }}
|
|
</button>
|
|
<NuxtLink
|
|
v-if="props.mode === 'edit' && savedSlug"
|
|
:to="versionHref(identity, activeLang)"
|
|
class="btn btn-ghost"
|
|
>
|
|
{{ t('common.cancel') }}
|
|
</NuxtLink>
|
|
<button class="btn btn-primary" type="submit" :disabled="saving">
|
|
{{ saving
|
|
? t('editor.saving')
|
|
: memberWrite
|
|
? t('editor.submitProposal')
|
|
: original
|
|
? t('editor.publishTranslation')
|
|
: creating
|
|
? t('editor.publishArticle')
|
|
: dirtyCount > 1
|
|
? t('editor.saveVersions', { n: dirtyCount })
|
|
: t('editor.saveChanges') }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="original" class="translate-banner">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<path d="M2 12h20" />
|
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
|
</svg>
|
|
<span>
|
|
{{ t('editor.translating') }}
|
|
<NuxtLink :to="versionHref(baseSlug, original.language)">{{ original.title }}</NuxtLink>
|
|
{{ t('editor.translatingTail') }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- a member writes nothing onto the wiki themselves, a page nor a new one:
|
|
the editor says so up front, and once a save has filed a request the
|
|
popup confirms it — closing that popup is what leaves the editor, so
|
|
the button cannot be pressed twice on an already-sent draft -->
|
|
<div v-if="memberWrite" class="banner banner-info">
|
|
<span class="glyph" aria-hidden="true">✎</span>
|
|
<span>{{ creating ? t('editor.proposalNoticeCreate') : t('editor.proposalNotice') }}</span>
|
|
</div>
|
|
|
|
<!-- the article's language versions: turn the editor to another language,
|
|
or add one — unsaved text waits on its own version's page -->
|
|
<div v-if="isVersioned" class="version-bar">
|
|
<span class="version-bar-label">{{ t('editor.languages') }}</span>
|
|
<button
|
|
v-for="d in versionList"
|
|
:key="d.language"
|
|
type="button"
|
|
class="lang-chip"
|
|
:class="{ active: d.language === activeLang }"
|
|
:lang="d.language"
|
|
:aria-pressed="d.language === activeLang"
|
|
:title="t('editor.editVersion', { name: languageName(d.language) })"
|
|
@click="switchLang(d.language)"
|
|
>
|
|
{{ languageName(d.language) }}
|
|
<span v-if="draftChanged(d)" class="dirty-dot" :title="t('editor.unsavedChanges')" />
|
|
</button>
|
|
<div v-if="freeLanguages.length" ref="addWrapEl" class="version-add">
|
|
<button
|
|
type="button"
|
|
class="lang-chip version-add-btn"
|
|
:aria-expanded="String(addOpen)"
|
|
:title="t('editor.addLanguageHint')"
|
|
@click="addOpen = !addOpen"
|
|
>
|
|
{{ t('editor.addLanguage') }}
|
|
</button>
|
|
<div v-if="addOpen" class="version-dropdown" role="listbox">
|
|
<button
|
|
v-for="l in freeLanguages"
|
|
:key="l.code"
|
|
type="button"
|
|
class="topic-option"
|
|
role="option"
|
|
:lang="l.code"
|
|
@click="addLanguage(l.code)"
|
|
>
|
|
<span>{{ l.name }}</span>
|
|
<span class="topic-count">{{ l.code.toUpperCase() }}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="article-title">{{ t('editor.titleLabel') }}</label>
|
|
<input
|
|
id="article-title"
|
|
v-model="title"
|
|
type="text"
|
|
class="title-input"
|
|
:placeholder="t('editor.titlePlaceholder')"
|
|
autofocus
|
|
/>
|
|
<div v-if="title.trim()" class="slug-preview">
|
|
{{ t('editor.urlLabel') }} <code>{{ slugPreview }}</code>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field-row">
|
|
<div class="field">
|
|
<label for="article-tags">
|
|
{{ t('editor.tagsLabel') }} <span class="hint">{{ t('editor.tagsHint') }}</span>
|
|
</label>
|
|
<input id="article-tags" v-model="tagsText" type="text" :placeholder="t('editor.tagsPlaceholder')" />
|
|
</div>
|
|
|
|
<div v-if="!isVersioned" class="field">
|
|
<label for="article-language">
|
|
{{ t('editor.languageLabel') }} <span class="hint">{{ t('editor.languageHint') }}</span>
|
|
</label>
|
|
<select id="article-language" v-model="language">
|
|
<option
|
|
v-for="l in languages"
|
|
:key="l.code"
|
|
:value="l.code"
|
|
:lang="l.code"
|
|
:disabled="l.taken"
|
|
:title="l.taken ? t('editor.languageTaken') : undefined"
|
|
>
|
|
{{ l.name }}{{ l.taken ? ` ${t('editor.alreadyWritten')}` : '' }}
|
|
</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="article-content" :lang="isVersioned ? activeLang : undefined">
|
|
{{ t('editor.contentLabel') }} <span class="hint">{{ t('editor.contentHint') }}</span>
|
|
</label>
|
|
<div ref="editorBodyEl" class="editor-body" :class="{ 'is-fullscreen': fullscreen }">
|
|
<div class="editor-toolbar">
|
|
<button type="button" class="mode-btn" :class="{ active: mode === 'write' }" @click="mode = 'write'">{{ t('editor.modeWrite') }}</button>
|
|
<button type="button" class="mode-btn" :class="{ active: mode === 'split' }" @click="mode = 'split'">{{ t('editor.modeSplit') }}</button>
|
|
<button type="button" class="mode-btn" :class="{ active: mode === 'preview' }" @click="mode = 'preview'">{{ t('editor.modePreview') }}</button>
|
|
<div class="toolbar-actions">
|
|
<button
|
|
type="button"
|
|
class="mode-btn toolbar-icon-btn"
|
|
:class="{ active: cheatsheet }"
|
|
:title="t('editor.cheatsheetHint')"
|
|
:aria-label="t('editor.cheatsheet')"
|
|
:aria-pressed="cheatsheet"
|
|
@click="cheatsheet = !cheatsheet"
|
|
>
|
|
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="10" />
|
|
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
|
<path d="M12 17h.01" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="mode-btn toolbar-icon-btn"
|
|
:title="fullscreen ? t('editor.exitFullscreenHint') : t('editor.fullscreen')"
|
|
:aria-label="fullscreen ? t('editor.exitFullscreen') : t('editor.enterFullscreen')"
|
|
:aria-pressed="fullscreen"
|
|
@click="toggleFullscreen"
|
|
>
|
|
<svg v-if="!fullscreen" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
|
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
|
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
|
|
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
|
</svg>
|
|
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
|
|
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
|
|
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
|
|
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="editor-grid" :class="`mode-${mode}`">
|
|
<div class="write-pane">
|
|
<textarea
|
|
id="article-content"
|
|
v-model="content"
|
|
class="md-input"
|
|
:lang="isVersioned ? activeLang : undefined"
|
|
:placeholder="contentPlaceholder"
|
|
spellcheck="false"
|
|
@keydown="onTabKeydown"
|
|
/>
|
|
</div>
|
|
<div class="preview-pane">
|
|
<div class="preview-label">{{ t('editor.livePreview') }}</div>
|
|
<MarkdownView :content="previewContent || `*${t('editor.noPreview')}*`" :article-lang="isVersioned ? activeLang : language" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<p v-if="errorMsg" class="form-error">{{ errorMsg }}</p>
|
|
|
|
<EditorCheatsheet :open="cheatsheet" @close="cheatsheet = false" />
|
|
|
|
<ConfirmModal
|
|
:open="discardOpen"
|
|
:title="t('editor.discardConfirm')"
|
|
:message="discardMessage"
|
|
:confirm-label="t('editor.discard')"
|
|
:cancel-label="t('editor.keepEditing')"
|
|
@confirm="discard"
|
|
@cancel="closeDiscardPrompt"
|
|
/>
|
|
|
|
<ProposalSentModal :open="sentOpen" :creating="creating" @close="closeSentPopup" />
|
|
</form>
|
|
</template>
|