Blog便签留言板设计(前端)
2026-10-16教学,留言板,留言墙,前端,html,script,设计

成品详情参见https://giogio.top/MessageBoard
## 便签墙的定位算法
> 左上角(**排序不随机**),但位置看起来是随手贴的(**位置随机**), 而且**不能互相重叠**。
三个约束一次满足的做法:**网格 + 抖动**。
每张便签在「格子左上角」基础上偏移 ±JITTER2,JITTER < 间隙 → 永不重叠
```typescript
// giogio/src/stickyLayout.ts
export const NOTE_SIZE = 320 // 便签外框边长
export const CELL_GAP = 88 // 格子间隙
export const CELL = NOTE_SIZE + CELL_GAP
export const JITTER = CELL_GAP * 0.72 // 抖动幅度
export function layoutNotes(ordered) {
const columns = columnsFor(ordered.length)
const placed = ordered.map((item, index) => {
const col = index % columns
const row = Math.floor(index / columns)
// 抖动只由 id 决定,类似于种子的伪随机
const rand = mulberry32(seedOf(item.id, 'sticky'))
const dx = (rand() - 0.5) * JITTER
const dy = (rand() - 0.5) * JITTER
return { id: item.id, index, x: col * CELL + dx, y: row * CELL + dy }
})
return { columns, rows, width: columns * CELL + CELL_GAP, ..., placed,
byId: new Map(placed.map((p) => [p.id, p])) }
}
```

在这里抖动是小于格子间隙的,如果抖动过大,会出现超过间隙,从而造成重叠效果,不用真随机是因为让一切更可预知。不然每次重置都会乱跑。
```typescript
function mulberry32(seed: number) {
let a = seed >>> 0
return function next() {
a = (a + 0x6d2b79f5) >>> 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/** 用 id + 名字做种子:即使两条留言 id 相邻,抖动也不会排成一条直线 */
function seedOf(id: number, salt: string) {
let h = 2166136261
const key = `${salt}:${id}`
for (let i = 0; i < key.length; i += 1) {
h ^= key.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return h >>> 0
}
```
即使是新加入的id,旧的也不会因为新id存在而被动性的改变自身位置。老便签只是整体平移一格,晃动的角度不变,观感自然。
关于便签如何排列:`columnsFor` 怎么决定"每行几张"
```typescript
const MAX_COLUMNS = 5 //最大列数
function columnsFor(count: number): number {
if (count <= 1) return 1
if (count <= 4) return 2
if (count <= 9) return 3
if (count <= 16) return 4
return MAX_COLUMNS
}
```
按**平方数**分段,所以刚好在 1 / 4 / 9 / 16 条时排出 1×1 / 2×2 / 3×3 / 4×4
这样的正方形,再多就封在 5 列。就像相册一样
为什么不采用不锁列数上限?究其原因是如果留言增加极多,且不清理旧留言。此时如果想找到之前写过的一段便签,则会非常困难,因为你不知道你的变迁被推到了右边还是左下。如果想要这个样式,这里没有"最优解",我的方法也有取舍,抛弃了符合直觉的逻辑,因为条数一多就会变成一个法棍。如果想采用宽矩形方法请参考:
```typescript
const TARGET_RATIO = 1.6
// 格子阵列的目标 宽 : 高 ≈ 16:10
//矩形长宽比会尽量保持在1.6
function columnsFor(count: number): number {
if (count <= 1) return 1
return Math.min(count, Math.round(Math.sqrt(count * TARGET_RATIO)))
}
```
### 搜索定位
位置计算放前端算的好处,搜索定位是直接的受益者:
```typescript
/** 把某张便签的中心挪到视口正中 */
function focusNote(id: number) {
const note = layout.value.byId.get(id) // 立刻知道在哪
const viewport = viewportRef.value
panX.value = viewport.clientWidth / 2 - (note.x + NOTE_SIZE / 2)
panY.value = viewport.clientHeight / 2 - (note.y + NOTE_SIZE / 2)
}
```
如果位置是后端存的,前端就得先请求、再计算、再平移,多一个来回,而且后端算的位置和实际屏幕宽度对不上。
## 五、一张便签 = 三层素材
素材(都是 64×64 的 PNG,`image-rendering: pixelated`):
| 素材 | 作用 | 在 64 单位里的位置 |
|---|---|---|
| `sticky-re.png` | 深色方纸,**本身已经转了约 12°** | 铺满,中心 ≈ (31.5, 31.5) |
| `sticky-msg.png` | 浅色便签本体(含一条白虚线) | 从 (7, 4) 起,**54×54** |
| `sticky-ding.png` | 红图钉 | 在 x 33..39、y 2..9 |
三层一叠,就是 `sticky.png` 那张参考图的样子




```html
<div class="note">
<div v-if="hasReply" class="layer re" @click="emit('toggle-reply', item.id)">…</div>
<div class="layer msg">…</div>
<div class="layer pin" @pointerdown="onPinDown"></div>
</div>
```
```css
.note {
--n: 320px; /* 必须和 stickyLayout.NOTE_SIZE 一致 */
--msg-l: calc(var(--n) * 0.109375); /* 7/64 */
--msg-t: calc(var(--n) * 0.0625); /* 4/64 */
--msg-s: calc(var(--n) * 0.84375); /* 54/64 */
}
/*素材是 64 单位,便签要放到 320px,所以每个坐标都写成 `n/64` 的百分比。 */
.msg { left: var(--msg-l); top: var(--msg-t);
width: var(--msg-s); height: var(--msg-s); }
```
## 如何实现“地图拖拽”效果
用 **Pointer Events** 而不是 mouse 事件:
因为是多端适配,不要忘记手机触摸, 同时解决鼠标 / 触摸 / 触控笔。
```typescript
function onPanDown(event: PointerEvent) {
if (event.button !== 0) return
if ((event.target as HTMLElement).closest('.note')) return // 点在便签上不平移
dragging.value = true
panStart = { px: event.clientX, py: event.clientY, x: panX.value, y: panY.value }
window.addEventListener('pointermove', onPanMove) // 挂到 window
window.addEventListener('pointerup', onPanUp)
}
function onPanMove(event: PointerEvent) {
if (!panStart) return
panX.value = panStart.x + (event.clientX - panStart.px)
panY.value = panStart.y + (event.clientY - panStart.py)
}
```
为什么监听到 `window` 上而不是元素上:**指针一旦移出元素,元素上的 `pointermove` 就断了**,画布会"跟丢"。
画布本体就是一个 `translate3d`:
```css
.wall-canvas {
position: absolute;
transform-origin: 0 0;
will-change: transform;
}
/* 程序化移动(搜索定位 / 复位)要顺滑;手拖的时候不能有过渡,否则会拖尾 */
.wall-canvas.moving { transition: transform 0.32s cubic-bezier(0.22, 0.61, 0.36, 1); }
```
### 如何实现按住图钉拖便签
便签的"静态位置"由算法算出来(写在内联 `left/top`),**手动拖拽的偏移单独存**,用 `transform` 叠加。两者分开的好处是:**重新排布时手拖的偏移不会丢**。
```typescript
const dragOffsets = reactive<Record<number, { x: number; y: number }>>({})
const dragTransform = computed(() =>
props.dragX || props.dragY ? `translate(${props.dragX}px, ${props.dragY}px)` : 'none'
)
```
图钉层铺满整张便签(`inset: 0`),但只让图钉那一小块能按:
```css
.pin {
inset: 0;
z-index: 4;
pointer-events: none; /* 整层不挡点击 */
}
.pin::after {
content: '';
position: absolute;
left: 46%; top: -2%; width: 16%; height: 16%; /* 图钉周围扩一圈,比较好按 */
pointer-events: auto; /* 只有这一小块能抓 */
cursor: grab;
}
```
### 如何实现点背面深色便签翻到前面
同一张便签里,`re` 层默认 `z-index: 1`、`msg` 层 `z-index: 2`;被点到前面时把 `re` 升到 3 就盖住了正文:
```css
.re { z-index: 1; cursor: pointer; }
.msg { z-index: 2; }
.note.is-front .re { z-index: 3; }
```
便签之间的层级则是"**把点过的挪到数组末尾**":
```typescript
const zOrder = ref<number[]>([])
function raise(id: number) {
zOrder.value = [...zOrder.value.filter((x) => x !== id), id]
}
function zIndexOf(id: number) {
const at = zOrder.value.indexOf(id)
return at < 0 ? 1 : at + 2
}
```
这部分逻辑比较简单,但也符合便签的交互逻辑,因为管理员回复不能去占用原本便签,或者是覆盖原本消息。这既不美观也不符合交互逻辑。设计一个双层便签,是对交互感最完美的诠释。注意如果有回复的话,才会出现双层标签。
### 实现正文过长时角标详细阅读
**判断字放不下不要靠字数估算**(中英文宽度差太多):
```typescript
function measure() {
const el = bodyRef.value
if (!el) return
overflowing.value = el.scrollHeight > el.clientHeight + 1
}
onMounted(() => {
measure()
// 因为我的网站像素字体是异步加载的(font-display: swap),字一换行数就变,要重量一次
if (document.fonts) document.fonts.ready.then(() => measure())
})
```
> 只 `onMounted` 就量一次是不够的。字体还没加载完时行数偏少,量出来"没溢出",等字换进来就溢出了。`document.fonts.ready` 之后必须**重量一次**。
小标用 `<details>`,但**把它的 `open` 状态拦掉**,让弹窗开关只由 `opened` 一个状态管:
```html
<details class="more">
<summary class="more-sum" @click.prevent="openReader" aria-label="展开全文"></summary>
</details>
```
```typescript
function openReader() {
opened.value = true
document.body.style.overflow = 'hidden' // 弹窗铺满屏幕时,底下不该还能滚
window.addEventListener('keydown', onKeydown) // Esc 关闭
emit('expand', props.item.id) // 关掉弹窗后这张便签在前面
}
function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') closeReader() }
```
### 详情弹窗`Teleport`
```html
<Teleport to="body">
<div v-if="opened" class="reader-mask" @click.self="closeReader">
<div class="reader" role="dialog" aria-modal="true">…</div>
</div>
</Teleport>
```
**为什么不能就地写 `position: fixed`?**
> 便签在 `.wall-canvas` 里,而这一层有 `transform: translate3d(...)`。
> **任何 transform / filter / perspective 祖先都会成为 `position: fixed` 的包含块**,
> 后代的 `fixed` 就不再相对视口定位,而是相对那个祖先 —— 结果就是"弹窗跟着画布一起平移"。
`Teleport to="body"` 把它挪到真正的根下,`fixed` 才有意义。
弹窗尺寸全部用 `min(像素, 百分比)`,所以跟着窗口大小自动变:
```css
.reader-mask {
position: fixed; inset: 0; z-index: 10002;
display: flex; align-items: center; justify-content: center;
padding: 24px;
background: rgba(0, 0, 0, 0.45);
}
.reader {
display: flex; flex-direction: column;
width: min(760px, 100%);
max-height: min(680px, 100%); /* 超长正文在内部滚 */
padding: 16px 18px 18px;
background-color: #e2e2e2;
border: var(--pixel-frame) solid transparent;
border-image: url('@/assets/border.png') 5 fill;
}
.reader-body { min-height: 0; overflow-y: auto; } /* flex 子项要 min-height:0 才能被压扁滚动 */
```
### 搜索、定位
搜索是**前端**的:关键词变化,重算命中集合,然后把第一条命中的便签平移到视口正中。
```typescript
watch(keyword, onSearch)
function recomputeHits() {
const key = keyword.value.trim().toLowerCase()
const next = new Set<number>()
for (const item of items.value) {
if (item.name.toLowerCase().includes(key) ||
item.message.toLowerCase().includes(key) ||
item.reply.toLowerCase().includes(key)) next.add(item.id)
}
hitIds.value = next
jumpTo(0) // 定位到第一条
}
function jumpNext() { jumpTo(hitCursor + 1) } // 回车跳到下一条,循环
```
搜索命中的便签会加 `.is-hit` 类,套一圈的红色虚线框。