CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 2m37s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m53s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Canceled after 3m25s
A tagged note was showing its tag twice — once where it was typed, once as a chip — and the duplicate was the loud copy. Now the chip row carries only what the body cannot say (a tag lifted off its own line, a label from the picker), and a `#tag` left mid-sentence is tinted in place. Which characters are a tag is asked of the CORE, the way the card already asks it which lines are checklist items: `extract_tag_spans` keeps the spans `extract_tags` throws away, and `body_tags` hands them to Kotlin. Offsets are UTF-16 code units, because `AnnotatedString` and JS both index that way and a char index lands mid-token the first time somebody writes an emoji. The web keeps its own matcher in markdown.ts, mirroring `line_tags` case for case. The inline ink is its own table, one Tailwind step deeper than the chip's. A chip brings its own -100 fill and reads against that alone; inline text sits on whatever the card is, including a gray-tagged card at neutral-200 — where the chip's -700 measured 3.98 (green), 4.11 (orange) and 4.34 (teal), under the 4.5 body text needs. At -800/-300 every hue lands 5.63-12.01 light and 7.20-10.84 dark across every palette and generated fill. Chips now carry the `#` on every surface. The via_tag branch that used to decide it is gone from the card, and Android's row said no hash at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
539 lines
24 KiB
Vue
539 lines
24 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, ref, watch } from "vue";
|
|
import { useNotesStore } from "../stores/notes";
|
|
import {
|
|
LABEL_CHIP_CLASSES,
|
|
NOTE_COLOR_KEYS,
|
|
NOTE_COLOR_LABELS,
|
|
NOTE_SWATCH_CLASSES,
|
|
noteCardClasses,
|
|
noteTintVars,
|
|
resolveLabelColor,
|
|
type NoteColor,
|
|
} from "../notes/colors";
|
|
import type { Note } from "../stores/notes";
|
|
import Icon from "./Icon.vue";
|
|
import LinkPreview from "./LinkPreview.vue";
|
|
import MarkdownText from "./MarkdownText.vue";
|
|
import {
|
|
cardIdAt,
|
|
draggingId,
|
|
DRAG_THRESHOLD_PX,
|
|
endCardDrag,
|
|
overId,
|
|
} from "../composables/useCardDrag";
|
|
import { formatReminder, formatTrashCountdown, isOverdue, trashDaysLeft } from "../notes/datetime";
|
|
import { useConfigStore } from "../stores/config";
|
|
|
|
const props = defineProps<{ note: Note; reorderable?: boolean; active?: boolean }>();
|
|
const emit = defineEmits<{
|
|
(e: "open", note: Note): void;
|
|
(e: "dragstart", note: Note): void;
|
|
(e: "dragend", note: Note): void;
|
|
/** The card this one was dropped ON, by id — the dragged card hit-tests the DOM
|
|
* for it, so an id is all it can know without looking the note up again. */
|
|
(e: "drop", targetId: string): void;
|
|
/** A reminder was completed or snoozed. The store reconciles itself, so the board
|
|
* needs nothing — this is for hosts holding their OWN list (the reminders lens
|
|
* fetches separately) that must re-query to drop the note they just cleared. */
|
|
(e: "reminder-changed"): void;
|
|
}>();
|
|
const notes = useNotesStore();
|
|
const config = useConfigStore();
|
|
|
|
// --- Retention countdown. A note in Trash is on a clock, and the card is the only
|
|
// place someone browsing Trash would ever find that out in time to restore it.
|
|
// Null whenever nothing is going to happen: not trashed, or retention turned off. ---
|
|
const trashDays = computed(() =>
|
|
props.note.trashed ? trashDaysLeft(props.note.deleted_at, config.trashRetentionDays) : null,
|
|
);
|
|
const trashCountdown = computed(() => formatTrashCountdown(trashDays.value));
|
|
// Same red the overdue reminder uses — the last few days are worth noticing.
|
|
const trashUrgent = computed(() => trashDays.value !== null && trashDays.value <= 3);
|
|
|
|
// The card previews the first image inline; non-image files show as compact chips.
|
|
const firstImage = computed(() => props.note.attachments.find((a) => a.mime.startsWith("image/")));
|
|
const otherAttachments = computed(() => props.note.attachments.filter((a) => !a.mime.startsWith("image/")));
|
|
|
|
// How much of a note the CARD shows. Android has always clamped to 8
|
|
// (`MAX_PREVIEW_LINES`); the web rendered the whole body, so one long note could
|
|
// produce a card taller than the screen and push everything else off the board.
|
|
//
|
|
// It matters more now that the title is gone (M13 step 4). The first line used to be
|
|
// the thing your eye caught; with one weight throughout, an unbounded card is just a
|
|
// wall, and the note next to it is the one you were looking for.
|
|
//
|
|
// Clamped in the STRING rather than with CSS `line-clamp`, which needs a
|
|
// `-webkit-box` and behaves unreliably around the block elements MarkdownText emits
|
|
// (lists, quotes, fenced code). This is deterministic, matches Android's semantics
|
|
// exactly, and skips parsing a body the card was never going to show.
|
|
const PREVIEW_LINES = 8;
|
|
|
|
// --- Links ------------------------------------------------------------------
|
|
//
|
|
// A note that is NOTHING but a URL is a link, and its preview is the whole card —
|
|
// showing the raw URL underneath a card that already says where it goes is saying the
|
|
// same thing twice, badly. A URL mentioned *inside* a note is a footnote to it, and
|
|
// gets a compact strip at the bottom instead.
|
|
//
|
|
// Whitespace either side still counts as lone: someone pasting a link rarely trims it.
|
|
const LONE_URL_RE = /^\s*(https?:\/\/[^\s<>"'\])]+)\s*$/;
|
|
|
|
const isLoneUrl = computed(() => LONE_URL_RE.test(props.note.body) && !props.note.items.length);
|
|
|
|
/** The preview for a lone-URL note — null while it is still being fetched, or if it
|
|
* could never be fetched at all. */
|
|
const loneUrlPreview = computed(() => {
|
|
if (!isLoneUrl.value) return null;
|
|
const url = props.note.body.trim();
|
|
return props.note.previews.find((p) => p.url === url) ?? null;
|
|
});
|
|
|
|
const bodyPreview = computed(() => {
|
|
const lines = props.note.body.split("\n");
|
|
if (lines.length <= PREVIEW_LINES) return props.note.body;
|
|
return lines.slice(0, PREVIEW_LINES).join("\n") + "\n…";
|
|
});
|
|
|
|
/** Tick a box without opening the note — the common gesture on a board.
|
|
*
|
|
* The index is the item's ordinal in the WHOLE body, which survives the preview
|
|
* clamp above because that only ever drops lines from the end. `updateItem` takes
|
|
* it as the item id, which is exactly what an id is now (M304). */
|
|
function toggleTask(index: number, checked: boolean) {
|
|
void notes.updateItem(props.note.id, String(index), { checked });
|
|
}
|
|
|
|
const root = ref<HTMLElement | null>(null);
|
|
|
|
// --- Drag-to-reorder. Pointer Events, gated behind an explicit grip handle so a
|
|
// plain tap/click/select never starts a drag by accident. `dragging` dims the
|
|
// source card; `dragOver` shows where the drop will land.
|
|
//
|
|
// Pointer rather than native HTML5 drag-and-drop because that API never fires from
|
|
// touch — on a phone this did nothing at all. One code path now covers mouse, touch
|
|
// and stylus. See composables/useCardDrag.ts for why the state is shared. ---
|
|
const canDrag = () => !!props.reorderable && !props.note.trashed;
|
|
const dragging = computed(() => draggingId.value === props.note.id);
|
|
const dragOver = computed(() => overId.value === props.note.id);
|
|
|
|
/** The pointer that owns the current gesture; null when no press is in flight. */
|
|
let activePointer: number | null = null;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
/** A press becomes a drag only after the threshold — below it, it's still a tap. */
|
|
let moved = false;
|
|
|
|
function onGripDown(e: PointerEvent) {
|
|
if (!canDrag()) return;
|
|
activePointer = e.pointerId;
|
|
startX = e.clientX;
|
|
startY = e.clientY;
|
|
moved = false;
|
|
// Capture so the gesture keeps reporting to this grip even once the finger has
|
|
// travelled onto another card — without it the stream stops at the first
|
|
// boundary crossing.
|
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
// Claims the gesture from the browser's own scroll/text-selection handling.
|
|
e.preventDefault();
|
|
}
|
|
|
|
function onGripMove(e: PointerEvent) {
|
|
if (activePointer !== e.pointerId) return;
|
|
if (!moved) {
|
|
if (Math.hypot(e.clientX - startX, e.clientY - startY) < DRAG_THRESHOLD_PX) return;
|
|
moved = true;
|
|
draggingId.value = props.note.id;
|
|
emit("dragstart", props.note);
|
|
}
|
|
overId.value = cardIdAt(e.clientX, e.clientY, props.note.id);
|
|
}
|
|
|
|
function onGripUp(e: PointerEvent) {
|
|
if (activePointer !== e.pointerId) return;
|
|
const target = moved ? overId.value : null;
|
|
const wasDragging = moved;
|
|
activePointer = null;
|
|
moved = false;
|
|
endCardDrag();
|
|
if (target) emit("drop", target);
|
|
// Always paired with dragstart, dropped or not, so the parent can clear its own
|
|
// tracked source rather than leaking it into the next gesture.
|
|
if (wasDragging) emit("dragend", props.note);
|
|
}
|
|
|
|
// Fires when the system takes the gesture away (an incoming call, a browser-level
|
|
// scroll taking over). Treated as a cancel: no reorder, no half-set state left.
|
|
function onGripCancel(e: PointerEvent) {
|
|
if (activePointer !== e.pointerId) return;
|
|
const wasDragging = moved;
|
|
activePointer = null;
|
|
moved = false;
|
|
endCardDrag();
|
|
if (wasDragging) emit("dragend", props.note);
|
|
}
|
|
|
|
// When this card becomes the keyboard-focused card, scroll it into view.
|
|
watch(
|
|
() => props.active,
|
|
(a) => {
|
|
if (a) root.value?.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
|
},
|
|
);
|
|
|
|
async function completeReminder(): Promise<void> {
|
|
await notes.completeReminder(props.note.id);
|
|
emit("reminder-changed");
|
|
}
|
|
|
|
async function snoozeReminder(minutes: number): Promise<void> {
|
|
await notes.snoozeReminder(props.note.id, minutes);
|
|
emit("reminder-changed");
|
|
}
|
|
|
|
// Takes the label, not its colour: a tag nobody has coloured derives one from its
|
|
// name, so chips carry the tag's identity rather than all being the same grey.
|
|
function labelChip(label: { name: string; color: string }): string {
|
|
return LABEL_CHIP_CLASSES[resolveLabelColor(label)] ?? LABEL_CHIP_CLASSES.default;
|
|
}
|
|
|
|
// Only the tags the BODY is not already showing. `via_tag` means exactly "backed by
|
|
// text still in the note" since M311, so a chip for one printed the same tag twice —
|
|
// once where it was typed, once in this row — and the loud copy was the duplicate. A
|
|
// tag left in prose is tinted where it sits instead (MarkdownInline). What survives
|
|
// here is what the body cannot say: a tag lifted off its own line, and a label added
|
|
// through the picker.
|
|
const chipLabels = computed(() => props.note.labels.filter((lb) => !lb.via_tag));
|
|
|
|
// The colour the operator stored for each of this note's tags, keyed by lowercased
|
|
// name — what MarkdownInline needs to tint a `#tag` the same as its chip would be.
|
|
// Lowercased because tags dedupe case-insensitively, so `#Todo` and `#todo` are one.
|
|
const tagColors = computed<Record<string, string>>(() => {
|
|
const map: Record<string, string> = {};
|
|
for (const lb of props.note.labels) map[lb.name.toLowerCase()] = lb.color;
|
|
return map;
|
|
});
|
|
|
|
// Per-card color popover (recolor without opening the editor).
|
|
const colorOpen = ref(false);
|
|
|
|
function swatch(color: string): string {
|
|
return NOTE_SWATCH_CLASSES[color as NoteColor] ?? NOTE_SWATCH_CLASSES.default;
|
|
}
|
|
|
|
function pickColor(color: NoteColor) {
|
|
colorOpen.value = false;
|
|
void notes.setColor(props.note.id, color);
|
|
}
|
|
|
|
function onDocMousedown(e: MouseEvent) {
|
|
if (colorOpen.value && root.value && !root.value.contains(e.target as Node)) colorOpen.value = false;
|
|
}
|
|
// Only listen for outside clicks while the popover is actually open.
|
|
watch(colorOpen, (open) => {
|
|
if (open) document.addEventListener("mousedown", onDocMousedown);
|
|
else document.removeEventListener("mousedown", onDocMousedown);
|
|
});
|
|
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown));
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
ref="root"
|
|
class="group relative mb-4 break-inside-avoid rounded-xl border border-[#b8b8b8] p-3 shadow-sm transition hover:shadow-md dark:border-[#404040]"
|
|
:class="[
|
|
noteCardClasses(note),
|
|
dragging ? 'opacity-40' : '',
|
|
dragOver
|
|
? 'scale-[1.02] shadow-lg ring-2 ring-brand ring-offset-2 ring-offset-white dark:ring-offset-neutral-950'
|
|
: '',
|
|
active ? 'ring-2 ring-brand' : '',
|
|
]"
|
|
:style="noteTintVars(note)"
|
|
:data-note-id="note.id"
|
|
>
|
|
<!-- THE EDGE LIVES HERE, NOT IN THE PALETTE, and that is the whole point of it.
|
|
Every card gets the same grey hairline whatever colour it is; the fill is the
|
|
only thing that varies. The version of this that was a `{hue}-900` border
|
|
failed because the line was both the loudest element on the card (1.56-2.09
|
|
against its own fill, where the fill managed 1.03-1.05 against the board) AND
|
|
carried the same information the fill did, so a board of them read as a grid
|
|
of outlines. A neutral line carries no information at all, which is exactly
|
|
what lets it be structure.
|
|
|
|
The two values are MATCHED, not picked by eye: each measures ~1.6-1.7 against
|
|
the card it edges (light 1.57-1.98, dark 1.58-1.73), so the edge has the same
|
|
authority in either theme. #404040 is `neutral-700`, which is what the default
|
|
card's border always was — promoted from one entry in the palette to the rule
|
|
for all of them. #b8b8b8 sits between `neutral-300` and `neutral-400`, neither
|
|
of which lands in range: 300 fades out at 1.18 on a gray-tagged card, 400
|
|
jumps to 2.52 and reads as a wireframe.
|
|
|
|
NOT a translucent black/white edge, which is the tidier-looking way to do this
|
|
and was measured and rejected: a border composites over the card's own fill,
|
|
so `border-white/20` comes out #56396d on a purple card and #a3c9c1 on a teal
|
|
one. Hue-coded edges are the thing being removed.
|
|
|
|
`shadow-sm`, back down from `shadow`: the border is the boundary again, so the
|
|
shadow is only depth. -->
|
|
<!-- TAGS FIRST. They used to sit under everything else, which on a tall note put
|
|
the one thing that says what a note IS below the fold of a glance. A board is
|
|
scanned, not read, and the answer to "which of these is about the thing I am
|
|
looking for" should be the first thing the eye lands on rather than the last.
|
|
|
|
Above the image and the body rather than beside them, because the body's first
|
|
line is the note's NAME (M13 steps 3 and 4) and a chip floated next to it would
|
|
compete with the thing that identifies the note. -->
|
|
<div v-if="chipLabels.length" class="mb-2 flex flex-wrap gap-1">
|
|
<!-- Every chip carries the `#`, not just the ones derived from body text. That
|
|
branch used to distinguish a `#tag` from a picker label; it cannot any more,
|
|
because a tag whose text is still in the body no longer reaches this row at
|
|
all. What is left is all the same thing to the eye and to the vocabulary —
|
|
and the hash is what keeps a lifted chip reading as the `#todo` somebody
|
|
typed. Android's row says the same, which it did not before. -->
|
|
<span
|
|
v-for="lb in chipLabels"
|
|
:key="lb.id"
|
|
class="rounded-full px-2 py-0.5 text-xs"
|
|
:class="labelChip(lb)"
|
|
>#{{ lb.name }}</span
|
|
>
|
|
</div>
|
|
|
|
<img
|
|
v-if="firstImage"
|
|
:src="firstImage.url"
|
|
alt=""
|
|
loading="lazy"
|
|
decoding="async"
|
|
class="mb-2 max-h-48 w-full cursor-pointer rounded-lg object-cover"
|
|
@click="emit('open', note)"
|
|
/>
|
|
<div v-if="otherAttachments.length" class="mb-2 flex flex-wrap gap-1">
|
|
<span
|
|
v-for="att in otherAttachments"
|
|
:key="att.id"
|
|
class="inline-flex max-w-full items-center gap-1 rounded-md bg-black/5 px-1.5 py-0.5 text-xs text-neutral-500 dark:bg-white/10 dark:text-neutral-400"
|
|
>
|
|
<Icon name="paperclip" />
|
|
<span class="max-w-[140px] truncate">{{ att.filename || "file" }}</span>
|
|
</span>
|
|
</div>
|
|
|
|
|
|
<!-- One render path: every note is a body plus, maybe, checkable items.
|
|
A focusable div rather than a <button>, because a checklist nests interactive
|
|
controls and those cannot live inside a button — and the card is the same
|
|
shape whether or not it happens to carry items today. -->
|
|
<div
|
|
role="button"
|
|
tabindex="0"
|
|
class="cursor-text rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
|
@click="emit('open', note)"
|
|
@keydown.enter="emit('open', note)"
|
|
>
|
|
<!-- A lone URL renders as its preview and nothing else. Until the fetch lands
|
|
— or if it never does — the URL itself stands in, so the card is never
|
|
blank and the link is never unreachable. -->
|
|
<LinkPreview v-if="loneUrlPreview" :preview="loneUrlPreview" />
|
|
<div v-else-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
|
|
<MarkdownText :text="bodyPreview" :tag-colors="tagColors" toggleable @toggle="toggleTask" />
|
|
</div>
|
|
<p
|
|
v-if="!note.body && !note.items.length && !note.attachments.length"
|
|
class="text-sm italic text-neutral-400"
|
|
>
|
|
Empty note
|
|
</p>
|
|
</div>
|
|
<!-- Inline links: a compact strip at the FOOT of the card, under the note's own
|
|
words rather than stacked on top of them. They were above the body until
|
|
M13 — which put a stranger's headline where the note's first line should be. -->
|
|
<div v-if="!isLoneUrl && note.previews.length" class="mt-2 flex flex-col gap-1">
|
|
<LinkPreview v-for="p in note.previews" :key="p.id" :preview="p" compact />
|
|
</div>
|
|
|
|
<!-- No separate checklist block any more. A checklist is lines of the body (M304),
|
|
so MarkdownText above draws it in place — which is what lets a list sit between
|
|
two paragraphs instead of always after them. Rendering both would have shown
|
|
every list twice. -->
|
|
|
|
<div v-if="note.remind_at" class="mt-2 flex flex-wrap items-center gap-1.5">
|
|
<span
|
|
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
|
:class="
|
|
isOverdue(note.remind_at)
|
|
? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
|
|
: 'bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300'
|
|
"
|
|
>
|
|
<svg
|
|
class="h-3 w-3"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<polyline points="12 6 12 12 16 14" />
|
|
</svg>
|
|
{{ formatReminder(note.remind_at) }}
|
|
<!-- Recurrence was only ever shown in the reminders list; carried onto the
|
|
card so unifying them didn't quietly drop it, and so a repeating note
|
|
reads as repeating on the board too. -->
|
|
<span v-if="note.recurrence" :title="`Repeats ${note.recurrence}`">· ↻ {{ note.recurrence }}</span>
|
|
</span>
|
|
|
|
<!-- Clearing a reminder is UPKEEP, and upkeep that costs three clicks is
|
|
upkeep people stop doing — so it happens right here rather than by
|
|
opening the note. Shown wherever a note carries a reminder, the board
|
|
included, not only in the reminders lens.
|
|
Always visible rather than hover-revealed: a finger cannot hover, and
|
|
these are the primary action for a due note (task 2697). -->
|
|
<button type="button" class="chip-btn" title="Mark this reminder done" @click.stop="completeReminder">
|
|
Done
|
|
</button>
|
|
<button type="button" class="chip-btn" title="Snooze 1 hour" @click.stop="snoozeReminder(60)">1h</button>
|
|
<button type="button" class="chip-btn" title="Snooze 1 day" @click.stop="snoozeReminder(1440)">1d</button>
|
|
</div>
|
|
|
|
<div v-if="trashCountdown" class="mt-2">
|
|
<span
|
|
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
|
:class="
|
|
trashUrgent
|
|
? 'bg-red-100 text-red-700 dark:bg-red-950/50 dark:text-red-300'
|
|
: 'bg-black/5 text-neutral-600 dark:bg-white/10 dark:text-neutral-300'
|
|
"
|
|
:title="`Permanently deleted ${config.trashRetentionDays} days after it was trashed`"
|
|
>
|
|
<svg
|
|
class="h-3 w-3"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
stroke-linejoin="round"
|
|
>
|
|
<circle cx="12" cy="12" r="10" />
|
|
<polyline points="12 6 12 12 16 14" />
|
|
</svg>
|
|
{{ trashCountdown }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- The card's controls: the drag grip, then the action set.
|
|
Placement is CSS's job, not the markup's — `.note-actions` in style.css puts
|
|
them where the device can actually use them. Where a pointer hovers they lift
|
|
out of flow into the floating top-corner pills they have always been; where
|
|
nothing can hover they stay here, in flow, as a footer row. The alternative
|
|
(a permanently visible overlay) sat on top of the note's own title, which is
|
|
what a phone was showing.
|
|
`hover-reveal` still handles the visibility half — see style.css. -->
|
|
<div class="note-actions">
|
|
<!-- Reorder is gated behind this grip so a normal click or tap never starts a
|
|
drag. Board views only (reorderable). A pointer-only affordance — there is
|
|
no keyboard equivalent, hence tabindex="-1". `touch-none` hands the gesture
|
|
to us instead of the browser's scrolling. -->
|
|
<button
|
|
v-if="canDrag()"
|
|
type="button"
|
|
tabindex="-1"
|
|
class="note-grip hover-reveal pointer-events-none flex touch-none cursor-grab items-center rounded-full bg-white/85 p-1 text-neutral-500 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition hover:text-neutral-800 active:cursor-grabbing group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:text-neutral-400 dark:ring-white/10 dark:hover:text-neutral-100"
|
|
title="Drag to reorder"
|
|
aria-label="Drag to reorder"
|
|
@pointerdown="onGripDown"
|
|
@pointermove="onGripMove"
|
|
@pointerup="onGripUp"
|
|
@pointercancel="onGripCancel"
|
|
>
|
|
<Icon name="grip" />
|
|
</button>
|
|
|
|
<div
|
|
class="note-actions-set hover-reveal pointer-events-none flex items-center gap-0.5 rounded-full bg-white/85 p-0.5 opacity-0 shadow-sm ring-1 ring-black/5 backdrop-blur-sm transition focus-within:pointer-events-auto focus-within:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100 dark:bg-neutral-900/85 dark:ring-white/10"
|
|
>
|
|
<template v-if="note.trashed">
|
|
<button type="button" class="icon-btn" title="Restore" aria-label="Restore" @click="notes.restore(note.id)">
|
|
<Icon name="restore" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
title="Delete forever"
|
|
aria-label="Delete forever"
|
|
@click="notes.deleteForever(note.id)"
|
|
>
|
|
<Icon name="trash" />
|
|
</button>
|
|
</template>
|
|
<template v-else>
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
title="Change color"
|
|
aria-label="Change color"
|
|
:aria-expanded="colorOpen"
|
|
@click.stop="colorOpen = !colorOpen"
|
|
>
|
|
<span
|
|
class="h-4 w-4 rounded-full border border-black/10 dark:border-white/20"
|
|
:class="swatch(note.color)"
|
|
></span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
:class="note.pinned ? 'text-brand-700 dark:text-brand' : ''"
|
|
:title="note.pinned ? 'Unpin' : 'Pin'"
|
|
:aria-label="note.pinned ? 'Unpin' : 'Pin'"
|
|
:aria-pressed="note.pinned"
|
|
@click="notes.setPinned(note.id, !note.pinned)"
|
|
>
|
|
<Icon name="pin" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
:title="note.archived ? 'Unarchive' : 'Archive'"
|
|
:aria-label="note.archived ? 'Unarchive' : 'Archive'"
|
|
@click="notes.setArchived(note.id, !note.archived)"
|
|
>
|
|
<Icon name="archive" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="icon-btn"
|
|
title="Move to trash"
|
|
aria-label="Move to trash"
|
|
@click="notes.trash(note.id)"
|
|
>
|
|
<Icon name="trash" />
|
|
</button>
|
|
</template>
|
|
|
|
<!-- Inside the action set rather than beside it, so it follows the set to
|
|
whichever corner or footer the device put it in. -->
|
|
<div
|
|
v-if="colorOpen"
|
|
class="note-swatches flex w-40 flex-wrap gap-1.5 rounded-lg border border-neutral-200 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
|
|
>
|
|
<button
|
|
v-for="key in NOTE_COLOR_KEYS"
|
|
:key="key"
|
|
type="button"
|
|
:title="NOTE_COLOR_LABELS[key]"
|
|
:aria-label="NOTE_COLOR_LABELS[key]"
|
|
class="h-6 w-6 rounded-full border border-black/10 transition hover:scale-110 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand"
|
|
:class="[NOTE_SWATCH_CLASSES[key], note.color === key ? 'ring-2 ring-brand' : '']"
|
|
@click.stop="pickColor(key)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|