The board places each card in the shortest column, instead of leaving it to CSS columns
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / integration (push) Successful in 1m37s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m58s
CI & Build / Python tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 40s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m38s
Android / Build the server image (push) Successful in 1s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 3m43s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / integration (push) Successful in 1m37s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 1m58s
CI & Build / Python tests (push) Successful in 13s
Android / Core and FFI clippy and tests (push) Successful in 40s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m38s
Android / Build the server image (push) Successful in 1s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m47s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 3m43s
Desktop (Tauri) / Update manifest (push) Skipped
CSS multi-column decides placement by balancing column heights, so the same notes could land differently at the same width: an edit left two cards alone in the fourth column with the middle two empty, and a refresh left the fourth empty beside a stack of three in the first. Cards now go, in board order, into the shortest column, leftmost on a tie, so the same notes at the same width always land the same way and every column fills before any stacks. This changes the reading order to match Android: the second note sits top-right rather than under the first. useMasonry measures the cards and watches them for height changes. Placement is a pure function, tested in notes/masonry.test.ts. Cards are absolutely placed, so TransitionGroup's move animation carries on working unchanged. Issue #6709. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -206,7 +206,7 @@ const tagColors = computed<Record<string, string>>(() => {
|
||||
<template>
|
||||
<div
|
||||
ref="root"
|
||||
class="group relative mb-2 break-inside-avoid sm:mb-4 rounded-xl border border-[#b8b8b8] p-3 shadow-sm transition hover:shadow-md dark:border-[#404040]"
|
||||
class="group relative rounded-xl border border-[#b8b8b8] p-3 shadow-sm transition hover:shadow-md dark:border-[#404040]"
|
||||
:class="[
|
||||
NOTE_CARD_SURFACE,
|
||||
dragging ? 'opacity-40' : '',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import NoteCard from "./NoteCard.vue";
|
||||
import { useMasonry } from "../composables/useMasonry";
|
||||
import type { Note } from "../stores/notes";
|
||||
|
||||
/**
|
||||
@@ -9,7 +10,7 @@ import type { Note } from "../stores/notes";
|
||||
* `activeId` rather than an index, because the board splits its notes across two
|
||||
* grids.
|
||||
*/
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
notes: Note[];
|
||||
/** The keyboard-focused note, if any. */
|
||||
activeId?: string | null;
|
||||
@@ -24,22 +25,27 @@ defineEmits<{
|
||||
(e: "drop", targetId: string): void;
|
||||
(e: "reminder-changed"): void;
|
||||
}>();
|
||||
|
||||
const { grid, gridStyle, cardStyle } = useMasonry(() => props.notes.map((n) => n.id));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- `board` is the transition name defined in style.css; `appear` covers first
|
||||
paint so a lens arrives eased rather than snapping in.
|
||||
Two columns even on a phone, as on Android, with a tighter gap there.
|
||||
NoteCard's bottom margin is the vertical half of this gap. -->
|
||||
useMasonry reads the column count and the gap back from these classes. -->
|
||||
<TransitionGroup
|
||||
ref="grid"
|
||||
tag="div"
|
||||
name="board"
|
||||
appear
|
||||
class="columns-2 gap-2 sm:gap-4 lg:columns-3 xl:columns-4"
|
||||
class="relative [--board-columns:2] [--board-gap:0.5rem] [column-gap:var(--board-gap)] sm:[--board-gap:1rem] lg:[--board-columns:3] xl:[--board-columns:4]"
|
||||
:style="gridStyle"
|
||||
>
|
||||
<NoteCard
|
||||
v-for="n in notes"
|
||||
:key="n.id"
|
||||
:style="cardStyle(n.id)"
|
||||
:note="n"
|
||||
:active="activeId === n.id"
|
||||
:reorderable="reorderable"
|
||||
|
||||
@@ -25,8 +25,8 @@ export function endCardDrag(): void {
|
||||
/**
|
||||
* The id of the card under this point, ignoring `self`.
|
||||
*
|
||||
* Reads the DOM rather than tracking geometry: in a CSS masonry (`columns-*`) the
|
||||
* visual order isn't derivable from the model order.
|
||||
* Reads the DOM rather than tracking geometry: in a masonry the visual order isn't
|
||||
* derivable from the model order.
|
||||
*/
|
||||
export function cardIdAt(x: number, y: number, self: string): string | null {
|
||||
const el = document.elementFromPoint(x, y)?.closest<HTMLElement>("[data-note-id]");
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
|
||||
import type { ComponentPublicInstance } from "vue";
|
||||
import { placeCards } from "../notes/masonry";
|
||||
import type { CardSlot } from "../notes/masonry";
|
||||
|
||||
/** A slot as inline style. The column count and gap stay in CSS, on the grid's own
|
||||
* classes, so a card has the right width before anything has been measured. */
|
||||
function slotStyle(slot: CardSlot | undefined): Record<string, string> {
|
||||
return {
|
||||
position: "absolute",
|
||||
top: `${slot?.top ?? 0}px`,
|
||||
left: `calc(${slot?.column ?? 0} * (100% + var(--board-gap)) / var(--board-columns))`,
|
||||
width: "calc((100% - (var(--board-columns) - 1) * var(--board-gap)) / var(--board-columns))",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lays out a NoteGrid: measures its cards and places each one with `placeCards`.
|
||||
*
|
||||
* `grid` is the template ref for the TransitionGroup. Its cards are found by
|
||||
* `data-note-id`, which NoteCard sets on its root.
|
||||
*/
|
||||
export function useMasonry(ids: () => string[]) {
|
||||
const grid = ref<ComponentPublicInstance | null>(null);
|
||||
const heights = shallowRef<ReadonlyMap<string, number>>(new Map());
|
||||
const columns = ref(1);
|
||||
const gap = ref(0);
|
||||
|
||||
const layout = computed(() => placeCards(ids(), heights.value, columns.value, gap.value));
|
||||
const gridStyle = computed(() => ({ height: `${layout.value.height}px` }));
|
||||
const cardStyle = (id: string) => slotStyle(layout.value.slots.get(id));
|
||||
|
||||
const gridEl = () => (grid.value?.$el ?? null) as HTMLElement | null;
|
||||
const cardEls = () => [...(gridEl()?.querySelectorAll<HTMLElement>(":scope > [data-note-id]") ?? [])];
|
||||
|
||||
function readTracks(): void {
|
||||
const el = gridEl();
|
||||
if (!el) return;
|
||||
const style = getComputedStyle(el);
|
||||
columns.value = parseInt(style.getPropertyValue("--board-columns"), 10) || 1;
|
||||
gap.value = parseFloat(style.columnGap) || 0;
|
||||
}
|
||||
|
||||
function measure(): void {
|
||||
const cards = cardEls();
|
||||
const wanted = new Set(ids());
|
||||
const next = new Map<string, number>();
|
||||
for (const card of cards) {
|
||||
const id = card.dataset.noteId as string;
|
||||
// A card on its way out is still in the DOM, and no longer takes up room.
|
||||
if (wanted.has(id)) next.set(id, card.offsetHeight);
|
||||
}
|
||||
const same = next.size === heights.value.size && [...next].every(([id, h]) => heights.value.get(id) === h);
|
||||
if (same) return;
|
||||
|
||||
// A card seen for the first time goes straight to its place. Left to the next
|
||||
// render, TransitionGroup would see it move there from the top of the board and
|
||||
// fly it across.
|
||||
const { slots } = placeCards(ids(), next, columns.value, gap.value);
|
||||
for (const card of cards) {
|
||||
const id = card.dataset.noteId as string;
|
||||
if (wanted.has(id) && !heights.value.has(id)) Object.assign(card.style, slotStyle(slots.get(id)));
|
||||
}
|
||||
heights.value = next;
|
||||
}
|
||||
|
||||
// Height follows content that has no event of its own: an edit, an image loading,
|
||||
// a width change rewrapping the text.
|
||||
const cardObserver = new ResizeObserver(measure);
|
||||
const gridObserver = new ResizeObserver(readTracks);
|
||||
|
||||
function observeCards(): void {
|
||||
cardObserver.disconnect();
|
||||
for (const card of cardEls()) cardObserver.observe(card);
|
||||
measure();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const el = gridEl();
|
||||
if (el) gridObserver.observe(el);
|
||||
readTracks();
|
||||
observeCards();
|
||||
});
|
||||
watch(() => ids().join("\n"), observeCards, { flush: "post" });
|
||||
onBeforeUnmount(() => {
|
||||
cardObserver.disconnect();
|
||||
gridObserver.disconnect();
|
||||
});
|
||||
|
||||
return { grid, gridStyle, cardStyle };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { placeCards } from "./masonry";
|
||||
|
||||
const heights = (entries: Record<string, number>) => new Map(Object.entries(entries));
|
||||
|
||||
describe("placing cards on the board", () => {
|
||||
it("uses every column before stacking", () => {
|
||||
const { slots } = placeCards(["a", "b", "c", "d"], heights({ a: 100, b: 100, c: 100, d: 100 }), 4, 16);
|
||||
expect([...slots.values()].map((s) => s.column)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("puts the next card under the shortest column, with the gap above it", () => {
|
||||
const { slots } = placeCards(["a", "b", "c"], heights({ a: 300, b: 50, c: 80 }), 2, 16);
|
||||
expect(slots.get("c")).toEqual({ column: 1, top: 66 });
|
||||
});
|
||||
|
||||
it("takes the leftmost column on a tie", () => {
|
||||
const { slots } = placeCards(["a", "b", "c"], heights({ a: 100, b: 100, c: 10 }), 2, 16);
|
||||
expect(slots.get("c")).toEqual({ column: 0, top: 116 });
|
||||
});
|
||||
|
||||
it("reports the tallest column as the board's height", () => {
|
||||
const { height } = placeCards(["a", "b", "c"], heights({ a: 300, b: 50, c: 80 }), 2, 16);
|
||||
expect(height).toBe(316);
|
||||
});
|
||||
|
||||
it("counts an unmeasured card as zero tall", () => {
|
||||
const { slots } = placeCards(["a", "b"], heights({ b: 100 }), 1, 16);
|
||||
expect(slots.get("b")).toEqual({ column: 0, top: 16 });
|
||||
});
|
||||
|
||||
it("is an empty board with no cards", () => {
|
||||
const { slots, height } = placeCards([], new Map(), 4, 16);
|
||||
expect(slots.size).toBe(0);
|
||||
expect(height).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
/** Where one card sits on the board. */
|
||||
export interface CardSlot {
|
||||
column: number;
|
||||
top: number;
|
||||
}
|
||||
|
||||
export interface BoardLayout {
|
||||
slots: Map<string, CardSlot>;
|
||||
/** The tallest column, so the board can reserve its own height. */
|
||||
height: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Places cards in board order, each into the column that is currently shortest and
|
||||
* the leftmost of any tie. The same notes at the same width always land the same way.
|
||||
*
|
||||
* A card with no measured height yet counts as zero.
|
||||
*/
|
||||
export function placeCards(
|
||||
ids: string[],
|
||||
heights: ReadonlyMap<string, number>,
|
||||
columns: number,
|
||||
gap: number,
|
||||
): BoardLayout {
|
||||
const bottoms = new Array<number>(Math.max(columns, 1)).fill(0);
|
||||
const slots = new Map<string, CardSlot>();
|
||||
for (const id of ids) {
|
||||
const column = bottoms.indexOf(Math.min(...bottoms));
|
||||
slots.set(id, { column, top: bottoms[column] });
|
||||
bottoms[column] += (heights.get(id) ?? 0) + gap;
|
||||
}
|
||||
return { slots, height: Math.max(...bottoms) };
|
||||
}
|
||||
@@ -131,10 +131,8 @@ body {
|
||||
* Vue's TransitionGroup does the FLIP itself; this supplies only the curve, fast and
|
||||
* small, so a card reads as having MOVED rather than performed.
|
||||
*
|
||||
* Leavers are NOT taken out of flow with `position: absolute`, the usual trick: this
|
||||
* masonry is CSS multi-column, and an absolutely positioned child escapes its column
|
||||
* to the container's origin. Staying in flow costs a small settle on removal, so the
|
||||
* leave is the shortest of the three.
|
||||
* Cards are absolutely placed (useMasonry), so a leaver fades where it stands while
|
||||
* the others move up. The leave is the shortest of the three.
|
||||
*
|
||||
* Reduced motion needs no case here: the global guard above collapses every duration. */
|
||||
.board-move {
|
||||
|
||||
Reference in New Issue
Block a user