Fast, cross-device labelling: type #groceries in a note and it becomes the "groceries" label. The body is the source of truth for tag-labels; manual picker labels stay independent (rule 28 — additive). - note_labels.via_tag (migration 0013) marks tag-sourced attachments. - parse_tags(): #tag at start-of-body or after whitespace, needs a letter (so #2024, URL #frags, mid#word are ignored). unit-tested. - _reconcile_tags() on create + body-update: attach labels for current #tags (find-or-create, case-insensitive), detach tag-labels whose tag was removed; never touches manual (via_tag=false) rows. - label picker (set_note_labels + editor onLabelsChange) now preserves tag-labels on save, so a picker action can't strip a label the #tag still mandates. - serialize via_tag; card/editor chips render tag-labels as "#name", and the editor hides the × on them (remove by editing the tag text). - LabelPicker builds manual NoteLabels (via_tag:false). Fourth and final item of M4.5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
105 lines
3.9 KiB
Vue
105 lines
3.9 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
|
import { useLabelsStore, type Label } from "../stores/labels";
|
|
import Icon from "./Icon.vue";
|
|
import type { NoteLabel } from "../stores/notes";
|
|
|
|
// A label attached via the picker is manual (via_tag: false).
|
|
const asManual = (lb: Label): NoteLabel => ({ id: lb.id, name: lb.name, color: lb.color, via_tag: false });
|
|
|
|
const props = defineProps<{ modelValue: NoteLabel[] }>();
|
|
const emit = defineEmits<{ (e: "update:modelValue", value: NoteLabel[]): void }>();
|
|
const labels = useLabelsStore();
|
|
|
|
const open = ref(false);
|
|
const filter = ref("");
|
|
const root = ref<HTMLElement | null>(null);
|
|
|
|
const selectedIds = computed(() => new Set(props.modelValue.map((lb) => lb.id)));
|
|
const shown = computed(() =>
|
|
labels.items.filter((lb) => lb.name.toLowerCase().includes(filter.value.trim().toLowerCase())),
|
|
);
|
|
const canCreate = computed(() => {
|
|
const name = filter.value.trim();
|
|
return name.length > 0 && !labels.items.some((lb) => lb.name.toLowerCase() === name.toLowerCase());
|
|
});
|
|
|
|
function toggle(lb: Label) {
|
|
const next = selectedIds.value.has(lb.id)
|
|
? props.modelValue.filter((x) => x.id !== lb.id)
|
|
: [...props.modelValue, asManual(lb)];
|
|
emit("update:modelValue", next);
|
|
}
|
|
|
|
async function createAndAdd() {
|
|
const created = await labels.create(filter.value.trim());
|
|
filter.value = "";
|
|
if (!selectedIds.value.has(created.id)) emit("update:modelValue", [...props.modelValue, asManual(created)]);
|
|
}
|
|
|
|
function onDocMousedown(e: MouseEvent) {
|
|
if (open.value && root.value && !root.value.contains(e.target as Node)) open.value = false;
|
|
}
|
|
onMounted(() => document.addEventListener("mousedown", onDocMousedown));
|
|
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown));
|
|
</script>
|
|
|
|
<template>
|
|
<div ref="root" class="relative">
|
|
<button type="button" class="icon-btn" title="Labels" aria-label="Labels" @click="open = !open">
|
|
<Icon name="tag" />
|
|
</button>
|
|
<div
|
|
v-if="open"
|
|
class="absolute bottom-full right-0 z-10 mb-1 w-56 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
|
|
>
|
|
<input
|
|
v-model="filter"
|
|
type="text"
|
|
placeholder="Label note…"
|
|
class="mb-1 w-full rounded-md border border-neutral-300 bg-white px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-600 dark:bg-neutral-900"
|
|
/>
|
|
<ul class="max-h-48 overflow-y-auto">
|
|
<li v-for="lb in shown" :key="lb.id">
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
|
|
@click="toggle(lb)"
|
|
>
|
|
<span
|
|
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border"
|
|
:class="
|
|
selectedIds.has(lb.id)
|
|
? 'border-brand bg-brand text-neutral-900'
|
|
: 'border-neutral-400 dark:border-neutral-500'
|
|
"
|
|
>
|
|
<svg
|
|
v-if="selectedIds.has(lb.id)"
|
|
class="h-3 w-3"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="3"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<path d="M20 6 9 17l-5-5" />
|
|
</svg>
|
|
</span>
|
|
<span class="truncate">{{ lb.name }}</span>
|
|
</button>
|
|
</li>
|
|
</ul>
|
|
<button
|
|
v-if="canCreate"
|
|
type="button"
|
|
class="mt-1 flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-brand-700 hover:bg-neutral-100 dark:text-brand dark:hover:bg-neutral-700"
|
|
@click="createAndAdd"
|
|
>
|
|
<Icon name="plus" /> Create "{{ filter.trim() }}"
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|