mirror of https://github.com/requarks/wiki
parent
c10e988c3a
commit
9408accc00
@ -0,0 +1,123 @@
|
||||
/*
|
||||
Generates `src/assets/emoji.generated.js` — the grouping the emoji picker is built from.
|
||||
|
||||
Two datasets have to meet for a picker to work here:
|
||||
|
||||
- `markdown-it-emoji` is the renderer's own vocabulary: `:shortcode:` is what gets written into a
|
||||
page, and anything outside that map would go in as text that never becomes an emoji. It has no
|
||||
notion of categories.
|
||||
- `unicode-emoji-json` carries the CLDR grouping and ordering — Smileys & Emotion, Animals & Nature,
|
||||
and so on — which is what the picker's tabs are, and no notion of shortcodes.
|
||||
|
||||
So this writes out the intersection: each group, in Unicode's own order, as `[shortcode, character]`
|
||||
pairs — the shortcode is what gets written into the page, the character is what the picker draws in its
|
||||
grid. The picker cannot offer an emoji the renderer would not draw, because every pair came from the
|
||||
renderer's own map.
|
||||
|
||||
The characters are written here rather than looked up at runtime, even though the editor already
|
||||
bundles that map for rendering. Reading it would mean importing `markdown-it-emoji/lib/data/full.mjs`
|
||||
from app code — a path into the package's internals rather than an entry point it publishes, and one
|
||||
more dependency for the bundler to discover. A few kB of duplicated characters is the cheaper side of
|
||||
that trade.
|
||||
|
||||
`unicode-emoji-json` is a devDependency for that reason: its data ends up here, and here is committed.
|
||||
|
||||
Usage: node scripts/generate-emoji.mjs [--check]
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import emojiByGroup from 'unicode-emoji-json/data-by-group.json' with { type: 'json' }
|
||||
import shortcodeToEmoji from 'markdown-it-emoji/lib/data/full.mjs'
|
||||
|
||||
const ROOT = fileURLToPath(new URL('../', import.meta.url))
|
||||
const OUT = path.join(ROOT, 'src/assets/emoji.generated.js')
|
||||
|
||||
/**
|
||||
* One shortcode per emoji, since several can point at the same character — `laughing` and `satisfied`
|
||||
* are both 😆, `+1` and `thumbsup` are both 👍.
|
||||
*
|
||||
* The first one wins, except that a name starting with a letter beats one that does not: the map's own
|
||||
* order is close to canonical, and `thumbsup` reads better in a page's source than `+1`.
|
||||
*/
|
||||
function buildShortcodeIndex() {
|
||||
const index = new Map()
|
||||
for (const [shortcode, emoji] of Object.entries(shortcodeToEmoji)) {
|
||||
const current = index.get(emoji)
|
||||
if (!current || (!/^[a-z]/.test(current) && /^[a-z]/.test(shortcode))) {
|
||||
index.set(emoji, shortcode)
|
||||
}
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
function build() {
|
||||
const index = buildShortcodeIndex()
|
||||
const groups = []
|
||||
const claimed = new Set()
|
||||
|
||||
for (const group of emojiByGroup) {
|
||||
const emoji = []
|
||||
for (const entry of group.emojis) {
|
||||
const shortcode = index.get(entry.emoji)
|
||||
// -> Skipped rather than invented: an emoji the renderer has no shortcode for cannot be written
|
||||
// into a page as an emoji, so it has no business in a picker
|
||||
if (shortcode && !claimed.has(shortcode)) {
|
||||
claimed.add(shortcode)
|
||||
emoji.push([shortcode, entry.emoji])
|
||||
}
|
||||
}
|
||||
if (emoji.length > 0) {
|
||||
groups.push({ slug: group.slug, name: group.name, emoji })
|
||||
}
|
||||
}
|
||||
|
||||
return { groups, claimed }
|
||||
}
|
||||
|
||||
function serialize({ groups, claimed }) {
|
||||
const body = groups
|
||||
.map((group) => {
|
||||
const pairs = group.emoji.map(([code, char]) => `['${code}', '${char}']`).join(', ')
|
||||
// -> Single quotes, so the generated file matches what oxfmt would write for the rest of `src`
|
||||
return ` {
|
||||
slug: '${group.slug}',
|
||||
name: '${group.name}',
|
||||
emoji: [${pairs}]
|
||||
}`
|
||||
})
|
||||
.join(',\n')
|
||||
|
||||
return `/*
|
||||
GENERATED by scripts/generate-emoji.mjs — do not edit.
|
||||
|
||||
The CLDR emoji groups, in Unicode's order, as \`[shortcode, character]\` pairs. The shortcode is one
|
||||
\`markdown-it-emoji\` accepts — what a page stores and what the renderer draws — and the character is
|
||||
what a picker shows. Regenerate with \`npm run emoji\`.
|
||||
|
||||
${claimed.size} emoji in ${groups.length} groups.
|
||||
*/
|
||||
export const EMOJI_GROUPS = [
|
||||
${body}
|
||||
]
|
||||
`
|
||||
}
|
||||
|
||||
const data = build()
|
||||
const output = serialize(data)
|
||||
|
||||
if (process.argv.includes('--check')) {
|
||||
const current = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : ''
|
||||
if (current !== output) {
|
||||
console.error('emoji.generated.js is out of date — run `npm run emoji`')
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`OK ${data.claimed.size} emoji, data up to date`)
|
||||
} else {
|
||||
fs.mkdirSync(path.dirname(OUT), { recursive: true })
|
||||
fs.writeFileSync(OUT, output)
|
||||
console.log(
|
||||
`wrote ${data.claimed.size} emoji in ${data.groups.length} groups to src/assets/emoji.generated.js (${Buffer.byteLength(output).toLocaleString()} B)`
|
||||
)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<w-menu
|
||||
ref="menuRef"
|
||||
class="translucent-menu"
|
||||
:anchor="props.anchor"
|
||||
:self="props.self"
|
||||
@show="onShow">
|
||||
<div class="code-block-menu">
|
||||
<div class="p-2">
|
||||
<!-- -> `transparent`: the panel behind this is acrylic, and an opaque field on it reads as a
|
||||
slab with the floating label straddling its edge -->
|
||||
<w-input
|
||||
ref="iptFilter"
|
||||
v-model="state.filter"
|
||||
dense
|
||||
outlined
|
||||
transparent
|
||||
clearable
|
||||
hide-bottom-space
|
||||
:label="t(`editor.codeBlock.filter`)"
|
||||
:aria-label="t(`editor.codeBlock.filter`)"
|
||||
@keyup:enter="chooseFirst">
|
||||
<template #prepend><w-icon name="la:search" /></template>
|
||||
</w-input>
|
||||
</div>
|
||||
<w-separator />
|
||||
<w-scroll-area class="code-block-menu-list">
|
||||
<w-list dense>
|
||||
<!--
|
||||
The handful worth reaching without typing, above the full set. Only while nothing is being
|
||||
filtered: with a filter on, two lists to read is worse than one, and every one of these is
|
||||
in the list below anyway.
|
||||
-->
|
||||
<template v-if="!isFiltering">
|
||||
<w-item
|
||||
v-for="language of COMMON_LANGUAGES"
|
||||
:key="`common-${language.id}`"
|
||||
clickable
|
||||
@click="choose(language.id)">
|
||||
<w-item-section>
|
||||
<w-item-label>{{ language.label }}</w-item-label>
|
||||
</w-item-section>
|
||||
<!-- -> A dash for plain text, which goes on the fence as nothing at all -->
|
||||
<w-item-section side>
|
||||
<div class="text-caption font-robotomono">{{ language.id || '—' }}</div>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<w-separator class="my-1" />
|
||||
</template>
|
||||
<w-item
|
||||
v-for="language of filtered"
|
||||
:key="language.id"
|
||||
clickable
|
||||
@click="choose(language.id)">
|
||||
<w-item-section>
|
||||
<w-item-label>{{ language.label }}</w-item-label>
|
||||
</w-item-section>
|
||||
<!-- -> The id, because that is what ends up on the fence and what a reader of the source
|
||||
will see -->
|
||||
<w-item-section side>
|
||||
<div class="text-caption font-robotomono">{{ language.id }}</div>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
<div
|
||||
v-if="filtered.length < 1"
|
||||
class="text-caption p-4 text-center text-black/60 dark:text-white/70">
|
||||
{{ t('editor.codeBlock.noResults') }}
|
||||
</div>
|
||||
</w-list>
|
||||
</w-scroll-area>
|
||||
</div>
|
||||
</w-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import hljs from 'highlight.js'
|
||||
|
||||
/**
|
||||
* Picks the language for a fenced code block.
|
||||
*
|
||||
* The list is whatever highlight.js has registered — asked at runtime rather than kept as a copy here,
|
||||
* so it cannot drift from what the renderer will actually highlight. Each entry carries the id that
|
||||
* goes on the fence and the name hljs calls it; the filter matches either, plus the aliases, so `md`
|
||||
* finds Markdown and `js` finds JavaScript.
|
||||
*/
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
anchor: {
|
||||
type: String,
|
||||
default: 'bottom left'
|
||||
},
|
||||
self: {
|
||||
type: String,
|
||||
default: 'top left'
|
||||
}
|
||||
})
|
||||
|
||||
// EMITS
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/**
|
||||
* The five worth having at the top. Labelled here rather than taking hljs's own names, which for these
|
||||
* read as `Plain text`, `HTML, XML` and `Bash` — precise, and not what someone scanning a shortlist is
|
||||
* looking for.
|
||||
*
|
||||
* Two of the ids are not what the list below would show either:
|
||||
* - `sh` and `md` are aliases rather than registered ids, so they are absent from that list; hljs
|
||||
* resolves them to Bash and Markdown, which is what highlights the block.
|
||||
* - Plain text has no id at all. A bare fence is how markdown says "no language", and it is what the
|
||||
* renderer already treats as unhighlighted — so there is nothing to put after the backticks. The
|
||||
* menu shows a dash where the others show their id.
|
||||
*/
|
||||
const COMMON_LANGUAGES = [
|
||||
{ id: '', label: 'Plain Text' },
|
||||
{ id: 'json', label: 'JSON' },
|
||||
{ id: 'md', label: 'Markdown' },
|
||||
{ id: 'sh', label: 'Bash Shell' },
|
||||
{ id: 'xml', label: 'XML' }
|
||||
]
|
||||
|
||||
/** Every registered language, by display name. Built once: the set cannot change at runtime. */
|
||||
const ALL_LANGUAGES = hljs
|
||||
.listLanguages()
|
||||
.map((id) => {
|
||||
const definition = hljs.getLanguage(id)
|
||||
return {
|
||||
id,
|
||||
label: definition?.name ?? id,
|
||||
// -> Searched but never shown: `sh` and `zsh` are how someone looks for Bash
|
||||
aliases: definition?.aliases ?? []
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
|
||||
// REFS
|
||||
|
||||
const menuRef = ref(null)
|
||||
const iptFilter = ref(null)
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
filter: ''
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const isFiltering = computed(() => state.filter.trim().length > 0)
|
||||
|
||||
const filtered = computed(() => {
|
||||
if (!isFiltering.value) {
|
||||
return ALL_LANGUAGES
|
||||
}
|
||||
const needle = state.filter.trim().toLowerCase()
|
||||
return ALL_LANGUAGES.filter(
|
||||
(language) =>
|
||||
language.label.toLowerCase().includes(needle) ||
|
||||
language.id.includes(needle) ||
|
||||
language.aliases.some((alias) => alias.includes(needle))
|
||||
)
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
/** Opens on the whole list with the caret in the filter, whatever the last visit left behind. */
|
||||
async function onShow() {
|
||||
state.filter = ''
|
||||
await nextTick()
|
||||
iptFilter.value?.focus()
|
||||
}
|
||||
|
||||
function choose(id) {
|
||||
emit('select', id)
|
||||
menuRef.value?.hide()
|
||||
}
|
||||
|
||||
/** Enter takes the top match, so a language can be chosen without leaving the keyboard. */
|
||||
function chooseFirst() {
|
||||
const first = isFiltering.value ? filtered.value[0] : COMMON_LANGUAGES[0]
|
||||
if (first) {
|
||||
choose(first.id)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.code-block-menu {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.code-block-menu-list {
|
||||
height: 320px;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,421 @@
|
||||
<template>
|
||||
<w-menu
|
||||
ref="menuRef"
|
||||
class="translucent-menu"
|
||||
:anchor="props.anchor"
|
||||
:self="props.self"
|
||||
@show="onShow">
|
||||
<div class="emoji-menu">
|
||||
<!--
|
||||
The tabs scroll the list rather than switching what is in it: one list with headings is what
|
||||
makes a search across everything, and a "Frequently Used" section above the groups, simple
|
||||
enough to be obviously correct.
|
||||
-->
|
||||
<div class="emoji-menu-tabs">
|
||||
<button
|
||||
v-for="tab of tabs"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="emoji-menu-tab"
|
||||
:class="{ 'is-active': state.activeTab === tab.key }"
|
||||
:aria-label="tab.label"
|
||||
@click="scrollToSection(tab.key)">
|
||||
<w-icon :name="tab.icon" size="20px" />
|
||||
<w-tooltip>{{ tab.label }}</w-tooltip>
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-2">
|
||||
<!-- -> `transparent`, for the same reason as the code block menu's filter: the panel is acrylic -->
|
||||
<w-input
|
||||
ref="iptSearch"
|
||||
v-model="state.search"
|
||||
dense
|
||||
outlined
|
||||
transparent
|
||||
clearable
|
||||
hide-bottom-space
|
||||
:label="t(`editor.emoji.search`)"
|
||||
:aria-label="t(`editor.emoji.search`)"
|
||||
@keyup:enter="chooseFirst">
|
||||
<template #prepend><w-icon name="la:search" /></template>
|
||||
</w-input>
|
||||
</div>
|
||||
<w-separator />
|
||||
<w-scroll-area ref="scrollRef" class="emoji-menu-list" @scroll="onScroll">
|
||||
<template v-if="isSearching">
|
||||
<div class="emoji-menu-heading">{{ t('editor.emoji.results') }}</div>
|
||||
<div class="emoji-menu-grid">
|
||||
<button
|
||||
v-for="[shortcode, character] of searchResults"
|
||||
:key="`found-${shortcode}`"
|
||||
type="button"
|
||||
class="emoji-menu-cell"
|
||||
:aria-label="shortcode"
|
||||
@click="choose(shortcode)"
|
||||
@mouseenter="state.preview = [shortcode, character]"
|
||||
@focus="state.preview = [shortcode, character]">
|
||||
{{ character }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="searchResults.length < 1" class="emoji-menu-empty">
|
||||
{{ t('editor.emoji.noResults') }}
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-for="section of sections" :key="section.key">
|
||||
<div :ref="(el) => setSectionRef(section.key, el)" class="emoji-menu-heading">
|
||||
{{ section.label }}
|
||||
</div>
|
||||
<div class="emoji-menu-grid">
|
||||
<button
|
||||
v-for="[shortcode, character] of section.emoji"
|
||||
:key="`${section.key}-${shortcode}`"
|
||||
type="button"
|
||||
class="emoji-menu-cell"
|
||||
:aria-label="shortcode"
|
||||
@click="choose(shortcode)"
|
||||
@mouseenter="state.preview = [shortcode, character]"
|
||||
@focus="state.preview = [shortcode, character]">
|
||||
{{ character }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</w-scroll-area>
|
||||
<w-separator />
|
||||
<!-- -> What the pointer is over, spelled out: the grid is 1,800 lookalikes and the shortcode is
|
||||
what actually lands in the page -->
|
||||
<div class="emoji-menu-preview">
|
||||
<div class="emoji-menu-preview-emoji">{{ state.preview ? state.preview[1] : '☝️' }}</div>
|
||||
<div class="min-w-0 flex-1 truncate">
|
||||
<div v-if="state.preview" class="text-body2 font-robotomono">
|
||||
:{{ state.preview[0] }}:
|
||||
</div>
|
||||
<div v-else class="text-body2 text-black/54 dark:text-white/70">
|
||||
{{ t('editor.emoji.pick') }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</w-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, reactive, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { EMOJI_GROUPS } from '@/assets/emoji.generated'
|
||||
|
||||
/**
|
||||
* Picks an emoji, as the `:shortcode:` the renderer understands.
|
||||
*
|
||||
* What goes into a page is the shortcode, not the character: that is what `markdown-it-emoji` replaces
|
||||
* and what the renderer then draws as a twemoji SVG, so a page reads the same everywhere regardless of
|
||||
* the fonts on the machine. The grid shows the characters, which is the one place the system font is
|
||||
* exactly what is wanted.
|
||||
*
|
||||
* The groups come from `assets/emoji.generated.js` — see `scripts/generate-emoji.mjs` for why the
|
||||
* grouping is generated rather than fetched or hand-kept.
|
||||
*/
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
anchor: {
|
||||
type: String,
|
||||
default: 'bottom left'
|
||||
},
|
||||
self: {
|
||||
type: String,
|
||||
default: 'top left'
|
||||
}
|
||||
})
|
||||
|
||||
// EMITS
|
||||
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** How many recent picks to keep, and where. Shared across editors and pages by design. */
|
||||
const RECENT_KEY = 'wiki.emoji.recent'
|
||||
const RECENT_MAX = 27
|
||||
|
||||
/** The tab strip, in the order the sections appear. Recents first, then Unicode's own order. */
|
||||
const GROUP_TABS = {
|
||||
smileys_emotion: { icon: 'mdi:emoticon-outline', label: 'editor.emoji.smileysEmotion' },
|
||||
people_body: { icon: 'mdi:hand-wave-outline', label: 'editor.emoji.peopleBody' },
|
||||
animals_nature: { icon: 'mdi:dog', label: 'editor.emoji.animalsNature' },
|
||||
food_drink: { icon: 'mdi:food-apple-outline', label: 'editor.emoji.foodDrink' },
|
||||
travel_places: { icon: 'mdi:car', label: 'editor.emoji.travelPlaces' },
|
||||
activities: { icon: 'mdi:basketball', label: 'editor.emoji.activities' },
|
||||
objects: { icon: 'mdi:lightbulb-outline', label: 'editor.emoji.objects' },
|
||||
symbols: { icon: 'mdi:percent-outline', label: 'editor.emoji.symbols' },
|
||||
flags: { icon: 'mdi:flag-outline', label: 'editor.emoji.flags' }
|
||||
}
|
||||
|
||||
const RECENT_TAB = { icon: 'mdi:clock-outline', label: 'editor.emoji.frequentlyUsed' }
|
||||
|
||||
// REFS
|
||||
|
||||
const menuRef = ref(null)
|
||||
const iptSearch = ref(null)
|
||||
const scrollRef = ref(null)
|
||||
|
||||
/** Each section's heading element, which is what a tab scrolls to and what the tabs track. */
|
||||
const sectionEls = new Map()
|
||||
|
||||
/** WScrollArea is the scrolling element itself, so its root is what has to be scrolled. */
|
||||
function scrollEl() {
|
||||
return scrollRef.value?.$el ?? null
|
||||
}
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
search: '',
|
||||
/** The `[shortcode, character]` under the pointer, shown in the footer. Null when nothing is. */
|
||||
preview: null,
|
||||
recent: [],
|
||||
activeTab: 'smileys_emotion'
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const isSearching = computed(() => state.search.trim().length > 0)
|
||||
|
||||
const tabs = computed(() => [
|
||||
...(state.recent.length > 0
|
||||
? [{ key: 'recent', icon: RECENT_TAB.icon, label: t(RECENT_TAB.label) }]
|
||||
: []),
|
||||
...EMOJI_GROUPS.map((group) => ({
|
||||
key: group.slug,
|
||||
icon: GROUP_TABS[group.slug]?.icon ?? 'mdi:emoticon-outline',
|
||||
// -> The generated English name is the fallback, for a group the dataset adds later
|
||||
label: GROUP_TABS[group.slug] ? t(GROUP_TABS[group.slug].label) : group.name
|
||||
}))
|
||||
])
|
||||
|
||||
const sections = computed(() => [
|
||||
...(state.recent.length > 0
|
||||
? [{ key: 'recent', label: t(RECENT_TAB.label), emoji: state.recent }]
|
||||
: []),
|
||||
...EMOJI_GROUPS.map((group) => ({
|
||||
key: group.slug,
|
||||
label: GROUP_TABS[group.slug] ? t(GROUP_TABS[group.slug].label) : group.name,
|
||||
emoji: group.emoji
|
||||
}))
|
||||
])
|
||||
|
||||
/*
|
||||
Matched on the shortcode alone, which is both the name and what gets written. Underscores are treated
|
||||
as spaces so that `open mouth` finds `open_mouth`, and every result is a shortcode the renderer knows,
|
||||
since that is where the list came from.
|
||||
*/
|
||||
const searchResults = computed(() => {
|
||||
const needle = state.search.trim().toLowerCase().replaceAll(' ', '_')
|
||||
return EMOJI_GROUPS.flatMap((group) =>
|
||||
group.emoji.filter(([shortcode]) => shortcode.includes(needle))
|
||||
)
|
||||
})
|
||||
|
||||
// METHODS
|
||||
|
||||
function setSectionRef(key, el) {
|
||||
if (el) {
|
||||
sectionEls.set(key, el)
|
||||
} else {
|
||||
sectionEls.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
/** Every pair by shortcode, for turning the stored recents back into something to draw. */
|
||||
const BY_SHORTCODE = new Map(EMOJI_GROUPS.flatMap((group) => group.emoji))
|
||||
|
||||
function readRecent() {
|
||||
try {
|
||||
const stored = JSON.parse(globalThis.localStorage?.getItem(RECENT_KEY) ?? '[]')
|
||||
if (!Array.isArray(stored)) {
|
||||
return []
|
||||
}
|
||||
/*
|
||||
Only shortcodes are stored, and only ones still in the data survive being read back: a name that
|
||||
has since gone would otherwise draw an empty cell nobody could explain.
|
||||
*/
|
||||
return stored
|
||||
.filter((shortcode) => BY_SHORTCODE.has(shortcode))
|
||||
.map((shortcode) => [shortcode, BY_SHORTCODE.get(shortcode)])
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function rememberRecent(shortcode) {
|
||||
const pairs = [
|
||||
[shortcode, BY_SHORTCODE.get(shortcode)],
|
||||
...state.recent.filter(([code]) => code !== shortcode)
|
||||
].slice(0, RECENT_MAX)
|
||||
state.recent = pairs
|
||||
try {
|
||||
globalThis.localStorage?.setItem(RECENT_KEY, JSON.stringify(pairs.map(([code]) => code)))
|
||||
} catch {
|
||||
// -> A browser refusing storage costs the recents list and nothing else
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens on the search field, with recents as they were left. */
|
||||
async function onShow() {
|
||||
state.search = ''
|
||||
state.preview = null
|
||||
state.recent = readRecent()
|
||||
state.activeTab = state.recent.length > 0 ? 'recent' : 'smileys_emotion'
|
||||
await nextTick()
|
||||
iptSearch.value?.focus()
|
||||
}
|
||||
|
||||
/*
|
||||
Both of these measure with `getBoundingClientRect`, deliberately.
|
||||
|
||||
`offsetTop` is relative to the nearest POSITIONED ancestor, which for these headings is the menu's
|
||||
floating panel rather than the scroll container -- so comparing it against the container's `scrollTop`
|
||||
was off by the panel's own offset, and every click landed on the section before the one asked for.
|
||||
Rects are in viewport space for both sides, so the difference is the real distance.
|
||||
*/
|
||||
function scrollToSection(key) {
|
||||
state.activeTab = key
|
||||
const list = scrollEl()
|
||||
const el = sectionEls.get(key)
|
||||
if (list && el) {
|
||||
list.scrollTop += el.getBoundingClientRect().top - list.getBoundingClientRect().top
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps the tab strip in step with what is on screen while scrolling. */
|
||||
function onScroll(event) {
|
||||
const listTop = event.target.getBoundingClientRect().top
|
||||
let active = sections.value[0]?.key
|
||||
for (const section of sections.value) {
|
||||
const el = sectionEls.get(section.key)
|
||||
// -> A heading counts as reached once it is at or above the top of the visible area
|
||||
if (el && el.getBoundingClientRect().top - listTop <= 8) {
|
||||
active = section.key
|
||||
}
|
||||
}
|
||||
state.activeTab = active
|
||||
}
|
||||
|
||||
function choose(shortcode) {
|
||||
rememberRecent(shortcode)
|
||||
emit('select', shortcode)
|
||||
menuRef.value?.hide()
|
||||
}
|
||||
|
||||
/** Enter takes the first match, so an emoji can be picked without leaving the keyboard. */
|
||||
function chooseFirst() {
|
||||
const first = isSearching.value ? searchResults.value[0] : sections.value[0]?.emoji?.[0]
|
||||
if (first) {
|
||||
choose(first[0])
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.emoji-menu {
|
||||
width: 340px;
|
||||
}
|
||||
|
||||
.emoji-menu-tabs {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/*
|
||||
A tab is an icon and an underline, which is all the strip needs: the label lives in the tooltip and in
|
||||
the heading the tab scrolls to.
|
||||
*/
|
||||
.emoji-menu-tab {
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 8px 0 6px;
|
||||
border-bottom: 2px solid transparent;
|
||||
color: inherit;
|
||||
opacity: 0.55;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
opacity 0.15s var(--ease-standard),
|
||||
border-color 0.15s var(--ease-standard);
|
||||
|
||||
&:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
border-bottom-color: var(--color-primary);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-menu-list {
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.emoji-menu-heading {
|
||||
padding: 8px 10px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.emoji-menu-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(9, 1fr);
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.emoji-menu-cell {
|
||||
display: flex;
|
||||
aspect-ratio: 1;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 4px;
|
||||
/* -> The emoji itself, at a size worth aiming at; the system font is the point here */
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
background-color: rgb(0 0 0 / 0.08);
|
||||
outline: none;
|
||||
|
||||
@at-root .body--dark & {
|
||||
background-color: rgb(255 255 255 / 0.14);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.emoji-menu-empty {
|
||||
padding: 24px 12px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.emoji-menu-preview {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.emoji-menu-preview-emoji {
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@ -0,0 +1,443 @@
|
||||
<template>
|
||||
<w-dialog v-model="dialogVisible" @hide="onDialogHide">
|
||||
<w-card class="link-picker" style="width: 860px; max-width: 90vw">
|
||||
<w-card-section class="card-header">
|
||||
<w-icon name="la:link" size="sm" class="mr-2" />
|
||||
<span>{{ props.title ?? t('linkPicker.title') }}</span>
|
||||
</w-card-section>
|
||||
<!-- -> Inset from the card's edges, as in the icon picker: the strip is a segmented control with
|
||||
a track of its own, so it sits ON the card rather than spanning it edge to edge -->
|
||||
<w-tabs class="m-2" v-model="state.currentTab" no-caps inline-label>
|
||||
<w-tab name="page" icon="la:file-alt" :label="t(`linkPicker.page`)" />
|
||||
<w-tab name="url" icon="la:globe" :label="t(`linkPicker.url`)" />
|
||||
</w-tabs>
|
||||
<w-separator />
|
||||
<w-tab-panels v-model="state.currentTab">
|
||||
<!-- ----------------------- -->
|
||||
<!-- A page of this wiki -->
|
||||
<!-- ----------------------- -->
|
||||
<w-tab-panel class="p-0" name="page">
|
||||
<div class="link-picker-browser flex flex-nowrap">
|
||||
<div class="link-picker-tree w-1/3">
|
||||
<w-scroll-area style="height: 300px">
|
||||
<!-- -> No side padding: the rows carry their own and span the column, as in the File
|
||||
Manager. Padding here would inset the highlight band as well. -->
|
||||
<div>
|
||||
<tree
|
||||
ref="treeComp"
|
||||
v-model:selected="state.currentFolderId"
|
||||
:nodes="state.treeNodes"
|
||||
:roots="state.treeRoots"
|
||||
:use-lazy-load="true"
|
||||
:context-action-list="[]"
|
||||
@lazy-load="treeLazyLoad" />
|
||||
</div>
|
||||
</w-scroll-area>
|
||||
</div>
|
||||
<div class="w-2/3">
|
||||
<w-scroll-area style="height: 300px">
|
||||
<w-list class="link-picker-list" dense>
|
||||
<w-item
|
||||
v-for="item of state.items"
|
||||
:key="item.id"
|
||||
clickable
|
||||
active-class="active"
|
||||
:active="item.type === `page` && item.path === state.path"
|
||||
@click="selectItem(item)">
|
||||
<w-item-section side>
|
||||
<w-icon :name="item.icon" size="sm" />
|
||||
</w-item-section>
|
||||
<w-item-section>
|
||||
<w-item-label>{{ item.title }}</w-item-label>
|
||||
<w-item-label caption class="font-robotomono">/{{ item.path }}</w-item-label>
|
||||
</w-item-section>
|
||||
</w-item>
|
||||
</w-list>
|
||||
<div
|
||||
v-if="state.items.length < 1 && !state.isFetching"
|
||||
class="text-caption text-center p-6 text-black/60 dark:text-white/70">
|
||||
{{ t('linkPicker.emptyFolder') }}
|
||||
</div>
|
||||
</w-scroll-area>
|
||||
</div>
|
||||
</div>
|
||||
</w-tab-panel>
|
||||
<!-- ----------------------- -->
|
||||
<!-- Anywhere else -->
|
||||
<!-- ----------------------- -->
|
||||
<w-tab-panel class="p-4" name="url">
|
||||
<w-input
|
||||
ref="iptUrl"
|
||||
v-model="state.url"
|
||||
outlined
|
||||
dense
|
||||
hide-bottom-space
|
||||
:label="t(`linkPicker.linkUrl`)"
|
||||
:aria-label="t(`linkPicker.linkUrl`)"
|
||||
placeholder="https://example.com/page" />
|
||||
<w-checkbox
|
||||
v-if="props.newTabOption"
|
||||
class="mt-4"
|
||||
v-model="state.openInNewTab"
|
||||
:label="t(`linkPicker.openInNewTab`)" />
|
||||
</w-tab-panel>
|
||||
</w-tab-panels>
|
||||
<w-separator />
|
||||
<!-- -> The same footer the icon picker has: what is about to be committed, spelled out, since
|
||||
both tabs can be half-filled and only one of them is the answer -->
|
||||
<w-card-section class="flex flex-nowrap items-center py-2">
|
||||
<w-icon
|
||||
:name="state.currentTab === `page` ? `la:file-alt` : `la:globe`"
|
||||
size="sm"
|
||||
color="primary" />
|
||||
<div class="min-w-0 flex-1 pl-3">
|
||||
<div class="text-caption text-grey">{{ t('linkPicker.selection') }}</div>
|
||||
<div class="text-body2 font-robotomono link-picker-href">{{ href || '—' }}</div>
|
||||
</div>
|
||||
</w-card-section>
|
||||
<w-separator />
|
||||
<w-card-actions class="card-actions">
|
||||
<w-space />
|
||||
<w-btn
|
||||
class="acrylic-btn"
|
||||
flat
|
||||
icon="la:times"
|
||||
:label="t(`common.actions.cancel`)"
|
||||
color="grey-7"
|
||||
padding="xs md"
|
||||
@click="onDialogCancel" />
|
||||
<w-btn
|
||||
icon="la:check"
|
||||
:label="props.okLabel ?? t(`common.actions.insert`)"
|
||||
unelevated
|
||||
color="primary"
|
||||
padding="xs md"
|
||||
:disabled="!canSubmit"
|
||||
@click="submit" />
|
||||
</w-card-actions>
|
||||
</w-card>
|
||||
</w-dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { dialogComponentEmits, useDialogComponent } from '@/composables/dialog'
|
||||
import { notify } from '@/composables/notify'
|
||||
|
||||
import fileTypes from '@/helpers/fileTypes'
|
||||
|
||||
import Tree from '@/components/TreeNav.vue'
|
||||
|
||||
import { usePageStore } from '@/stores/page'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
|
||||
/**
|
||||
* Picks a link target: a page of this wiki, or any URL.
|
||||
*
|
||||
* Written to be opened from anywhere that needs one — the markdown editor's Insert Link, the target of
|
||||
* a page relation — so it decides nothing about what the link is FOR. It answers with
|
||||
* `{ href, openInNewTab, title }` and leaves the caller to render that as markdown, store it on a
|
||||
* relation, or whatever else.
|
||||
*
|
||||
* dialog({ component: LinkPickerDialog }).onOk(({ href }) => ...)
|
||||
*/
|
||||
|
||||
// PROPS
|
||||
|
||||
const props = defineProps({
|
||||
/** Card heading. The insert-a-link wording by default. */
|
||||
title: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
/** Label on the confirm button, for a caller that is selecting rather than inserting. */
|
||||
okLabel: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
/** An href to open on, so re-opening the picker starts where the last choice left it. */
|
||||
initialHref: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
/**
|
||||
* Whether the URL tab offers "open in a new tab". Off for a caller with nowhere to put the answer —
|
||||
* a control whose effect is discarded is worse than no control.
|
||||
*/
|
||||
newTabOption: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
// EMITS
|
||||
|
||||
defineEmits([...dialogComponentEmits])
|
||||
|
||||
// DIALOG
|
||||
|
||||
const { dialogVisible, onDialogHide, onDialogOK, onDialogCancel } = useDialogComponent()
|
||||
|
||||
// STORES
|
||||
|
||||
const pageStore = usePageStore()
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
// I18N
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
// REFS
|
||||
|
||||
const treeComp = ref(null)
|
||||
const iptUrl = ref(null)
|
||||
|
||||
// DATA
|
||||
|
||||
const state = reactive({
|
||||
currentTab: 'page',
|
||||
/** Folder whose contents the right-hand pane lists. Null is the site root. */
|
||||
currentFolderId: null,
|
||||
treeNodes: {},
|
||||
treeRoots: [],
|
||||
items: [],
|
||||
/** The chosen page, as a slash path with no leading slash. Only a row in the list sets it. */
|
||||
path: '',
|
||||
/** Title of the page the path came from, which a caller can use as the link's text. */
|
||||
pageTitle: '',
|
||||
isFetching: false,
|
||||
url: 'https://',
|
||||
openInNewTab: false
|
||||
})
|
||||
|
||||
// COMPUTED
|
||||
|
||||
const href = computed(() =>
|
||||
state.currentTab === 'page' ? (state.path ? `/${state.path}` : '') : state.url.trim()
|
||||
)
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (state.currentTab === 'page') {
|
||||
return state.path.length > 0
|
||||
}
|
||||
// -> The scheme alone is what the field is prefilled with, so it does not count as an answer
|
||||
return href.value.length > 0 && !/^[a-z][a-z0-9+.-]*:\/*$/i.test(href.value)
|
||||
})
|
||||
|
||||
// WATCHERS
|
||||
|
||||
watch(
|
||||
() => state.currentFolderId,
|
||||
(folderId) => loadTree({ parentId: folderId })
|
||||
)
|
||||
|
||||
// METHODS
|
||||
|
||||
/**
|
||||
* The message an API failure should be reported with — the server's own if it sent one, since ky
|
||||
* throws before the caller ever sees the body.
|
||||
*/
|
||||
async function apiErrorMessage(err, fallback) {
|
||||
const message = await err.response
|
||||
?.json()
|
||||
.then((b) => b?.message)
|
||||
.catch(() => null)
|
||||
return message || err.message || fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads one folder into the tree, and — when that folder is the selected one — into the list beside it.
|
||||
*
|
||||
* `initLoad` also asks for the folders above the one being listed, so that opening on a page buried a
|
||||
* few levels down draws its whole branch from a single request. Those extra entries come back flagged
|
||||
* `isAncestor` and belong in the tree only, never in the list.
|
||||
*/
|
||||
async function loadTree({ parentId = null, parentPath = null, initLoad = false }) {
|
||||
if (state.isFetching) {
|
||||
return
|
||||
}
|
||||
state.isFetching = true
|
||||
const isCurrentFolder = (parentId ?? null) === state.currentFolderId
|
||||
if (isCurrentFolder) {
|
||||
state.items = []
|
||||
}
|
||||
try {
|
||||
const entries = await API_CLIENT.get(`sites/${siteStore.id}/tree`, {
|
||||
searchParams: {
|
||||
...(parentId ? { parentId } : {}),
|
||||
...(parentPath ? { parentPath } : {}),
|
||||
types: 'folder,page',
|
||||
includeAncestors: initLoad,
|
||||
includeRootFolders: initLoad
|
||||
}
|
||||
}).json()
|
||||
for (const entry of entries ?? []) {
|
||||
const path = entry.folderPath ? `${entry.folderPath}/${entry.fileName}` : entry.fileName
|
||||
if (entry.type === 'folder') {
|
||||
state.treeNodes[entry.id] = {
|
||||
folderPath: entry.folderPath,
|
||||
fileName: entry.fileName,
|
||||
title: entry.title,
|
||||
children: state.treeNodes[entry.id]?.children ?? []
|
||||
}
|
||||
if (entry.folderPath) {
|
||||
const parentOfEntry = parentId ?? findFolderIdByPath(entry.folderPath)
|
||||
if (
|
||||
entry.id !== parentOfEntry &&
|
||||
!state.treeNodes[parentOfEntry]?.children?.includes(entry.id)
|
||||
) {
|
||||
state.treeNodes[parentOfEntry]?.children?.push(entry.id)
|
||||
}
|
||||
} else if (!state.treeRoots.includes(entry.id)) {
|
||||
state.treeRoots.push(entry.id)
|
||||
}
|
||||
}
|
||||
// -> An ancestor is drawn in the tree to give the branch its shape; it is not IN this folder
|
||||
if (isCurrentFolder && !entry.isAncestor) {
|
||||
state.items.push({
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
title: entry.title,
|
||||
path,
|
||||
icon: entry.type === 'folder' ? fileTypes.folder.icon : fileTypes.page.icon
|
||||
})
|
||||
}
|
||||
}
|
||||
// -> Folders first, as the File Manager lists them, then by what they are called
|
||||
state.items.sort((a, b) =>
|
||||
a.type === b.type ? a.title.localeCompare(b.title) : a.type === 'folder' ? -1 : 1
|
||||
)
|
||||
} catch (err) {
|
||||
notify({
|
||||
type: 'negative',
|
||||
message: t('linkPicker.loadFailed'),
|
||||
caption: await apiErrorMessage(err, 'An unexpected error occured.')
|
||||
})
|
||||
}
|
||||
if (parentId) {
|
||||
treeComp.value?.setLoaded(parentId)
|
||||
}
|
||||
state.isFetching = false
|
||||
}
|
||||
|
||||
function treeLazyLoad(nodeId, isCurrent, { done }) {
|
||||
loadTree({ parentId: nodeId }).then(done)
|
||||
}
|
||||
|
||||
/** The id of an already-loaded folder, addressed the way a path addresses it. */
|
||||
function findFolderIdByPath(path) {
|
||||
if (!path) {
|
||||
return null
|
||||
}
|
||||
const entry = Object.entries(state.treeNodes).find(
|
||||
([, node]) => (node.folderPath ? `${node.folderPath}/${node.fileName}` : node.fileName) === path
|
||||
)
|
||||
return entry?.[0] ?? null
|
||||
}
|
||||
|
||||
/** A folder is somewhere to look; a page is the answer. */
|
||||
function selectItem(item) {
|
||||
if (item.type === 'folder') {
|
||||
state.currentFolderId = item.id
|
||||
treeComp.value?.setOpened(item.id)
|
||||
return
|
||||
}
|
||||
state.path = item.path
|
||||
state.pageTitle = item.title
|
||||
}
|
||||
|
||||
function submit() {
|
||||
onDialogOK({
|
||||
href: href.value,
|
||||
// -> Only ever true for a URL: a page of this wiki opens in the tab the reader is already in
|
||||
openInNewTab: state.currentTab === 'url' && props.newTabOption && state.openInNewTab,
|
||||
title: state.currentTab === 'page' ? state.pageTitle : ''
|
||||
})
|
||||
}
|
||||
|
||||
// MOUNTED
|
||||
|
||||
onMounted(async () => {
|
||||
/*
|
||||
An href that is already set decides which tab opens and what it starts on, so re-opening the picker
|
||||
on a link that exists starts from that link rather than from nothing. A page shows up as the
|
||||
highlighted row once its folder is listed, and in the footer either way.
|
||||
*/
|
||||
if (props.initialHref) {
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(props.initialHref) || props.initialHref.startsWith('//')) {
|
||||
state.currentTab = 'url'
|
||||
state.url = props.initialHref
|
||||
} else {
|
||||
state.path = props.initialHref.replace(/^\/+/, '')
|
||||
}
|
||||
}
|
||||
|
||||
// -> Opens on the folder holding the page being edited, which is where a link is most often going
|
||||
const startFolder = pageStore.folderPath
|
||||
await loadTree({ parentPath: startFolder, initLoad: true })
|
||||
const startFolderId = findFolderIdByPath(startFolder)
|
||||
if (startFolderId) {
|
||||
const parts = startFolder.split('/')
|
||||
for (let i = 1; i <= parts.length; i++) {
|
||||
const ancestorId = findFolderIdByPath(parts.slice(0, i).join('/'))
|
||||
if (ancestorId) {
|
||||
treeComp.value?.setOpened(ancestorId)
|
||||
}
|
||||
}
|
||||
state.currentFolderId = startFolderId
|
||||
} else {
|
||||
// -> Already at the root, which the watcher above will not fire for
|
||||
await loadTree({})
|
||||
}
|
||||
|
||||
if (state.currentTab === 'url') {
|
||||
await nextTick()
|
||||
iptUrl.value?.focus()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.link-picker {
|
||||
&-browser {
|
||||
height: 300px;
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
/* -> The tree column carries the recessed surface, as it does in the File Manager */
|
||||
&-tree {
|
||||
height: 300px;
|
||||
|
||||
@at-root .body--light & {
|
||||
background-color: $blue-grey-1;
|
||||
}
|
||||
@at-root .body--dark & {
|
||||
background-color: $dark-4;
|
||||
}
|
||||
}
|
||||
|
||||
&-list {
|
||||
padding: 8px 12px;
|
||||
|
||||
> .w-item {
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
|
||||
&.active {
|
||||
background-color: var(--color-primary);
|
||||
color: #fff;
|
||||
|
||||
.w-item-label--caption {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&-href {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,97 +0,0 @@
|
||||
<template>
|
||||
<w-menu ref="menuRef" :anchor="anchor" :self="self" @hide="onHide">
|
||||
<div class="p-3">
|
||||
<!--
|
||||
The slot edits a WORKING COPY, not the model. `set` commits it and closes; dismissing the
|
||||
popup any other way discards it. That is the contract the callers are written against --
|
||||
an inline title editor must not write through on every keystroke.
|
||||
|
||||
Handed over as ONE reactive object rather than as separate slot props, because the field
|
||||
inside binds `v-model="scope.value"` and needs a real setter to write through. Slot props
|
||||
are rebuilt on every render, so a plain value passed per-key would be a snapshot and the
|
||||
assignment would go nowhere.
|
||||
-->
|
||||
<slot :scope="scope" />
|
||||
</div>
|
||||
</w-menu>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import WMenu from './WMenu.vue'
|
||||
|
||||
/**
|
||||
* Edit a value in a popup anchored to the element this sits inside.
|
||||
*
|
||||
* Simplification: the component this replaces also offered its own buttons, validation, a title,
|
||||
* and a `label-set` / `label-cancel` pair. Both callers here supply their own field and commit on
|
||||
* Enter, so this is the popup and the working copy and nothing else.
|
||||
*
|
||||
* `auto-save` is accepted and ignored: it made the original commit on dismissal rather than
|
||||
* discard. Neither caller depends on that -- both commit explicitly from the field's Enter key --
|
||||
* and silently saving a half-typed title on a stray click is the worse default.
|
||||
*/
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: null,
|
||||
default: null
|
||||
},
|
||||
anchor: {
|
||||
type: String,
|
||||
default: 'bottom left'
|
||||
},
|
||||
self: {
|
||||
type: String,
|
||||
default: 'top left'
|
||||
},
|
||||
/** Accepted for call-site compatibility; see the note above. */
|
||||
autoSave: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'save', 'cancel'])
|
||||
|
||||
const menuRef = ref(null)
|
||||
const draft = ref(props.modelValue)
|
||||
|
||||
// -> Re-seed whenever the source changes, so re-opening never shows a stale edit
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(value) => {
|
||||
draft.value = value
|
||||
}
|
||||
)
|
||||
|
||||
const scope = reactive({
|
||||
value: computed({
|
||||
get: () => draft.value,
|
||||
set: (v) => {
|
||||
draft.value = v
|
||||
}
|
||||
}),
|
||||
initialValue: computed(() => props.modelValue),
|
||||
set: () => commit(),
|
||||
cancel: () => cancel()
|
||||
})
|
||||
|
||||
function commit() {
|
||||
if (draft.value !== props.modelValue) {
|
||||
emit('update:modelValue', draft.value)
|
||||
emit('save', draft.value)
|
||||
}
|
||||
menuRef.value?.hide()
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
draft.value = props.modelValue
|
||||
menuRef.value?.hide()
|
||||
}
|
||||
|
||||
/** Dismissed by click-away or Escape: the working copy goes back to the source, unsaved. */
|
||||
function onHide() {
|
||||
draft.value = props.modelValue
|
||||
emit('cancel')
|
||||
}
|
||||
</script>
|
||||
Loading…
Reference in new issue