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

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:
2026-10-30 09:56:58 -04:00
co-authored by Claude Opus 5.5
parent 7fca1f6c7f
commit 0f787c6363
8 changed files with 177 additions and 11 deletions
@@ -530,7 +530,7 @@ private fun DrawerRow(
* Staggered rather than a uniform grid because notes are wildly different heights
* — a one-line thought beside a twelve-item checklist — and forcing them to a
* common height either clips the long ones or strands whitespace under the short
* ones. This is the Compose equivalent of the CSS multi-column `NoteGrid.vue` uses.
* ones. `NoteGrid.vue` places cards the same way, each into the shortest column.
*/
@Composable
private fun NoteBoard(
+1 -1
View File
@@ -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' : '',
+9 -3
View File
@@ -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"
+2 -2
View File
@@ -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]");
+91
View File
@@ -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 };
}
+38
View File
@@ -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);
});
});
+33
View File
@@ -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) };
}
+2 -4
View File
@@ -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 {