发现一个不错的网站,这个网站作者是:https://github.com/uninto/notes,纯html、css、js实现。
有人说这个类似之前的许愿墙,我觉得也可以把这个元素加进去,毕竟我群里很多宅男腐女,最喜欢这个玩意儿了,晚些我直接上线到我的工具箱里
因为这个只有一个前端页面,代码如下,我就准备做个后端出来,到时候重新开个帖子,供大家使用。
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>便签墙</title>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background-image: linear-gradient(0deg, #eee 1px, transparent 0),
linear-gradient(90deg, #eee 1px, transparent 0);
background-size: 30px 30px;
color: #333;
min-height: 100dvh;
overflow: hidden;
}
body.has-maximized-card {
overflow: hidden;
}
body.is-mobile {
overflow-y: auto;
}
#board {
position: relative;
width: 100vw;
height: 100dvh;
overflow: hidden;
}
body.is-mobile #board {
height: auto;
min-height: 100dvh;
}
.card {
position: absolute;
width: 220px;
border-radius: 12px;
box-shadow: 0 16px 35px rgba(0, 0, 0, 0.2);
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.08);
overflow: hidden;
opacity: 0;
transform-origin: center;
transition: transform 0.35s ease, opacity 0.35s ease, left 0.35s ease,
top 0.35s ease, width 0.35s ease, height 0.35s ease,
border-radius 0.35s ease;
}
.card.dragging {
transition: none;
box-shadow: 0 22px 45px rgba(0, 0, 0, 0.35);
}
.card.maximized {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
height: 100dvh;
border-radius: 0;
box-shadow: 0 28px 60px rgba(0, 0, 0, 0.4);
}
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
background: rgba(255, 255, 255, 0.7);
cursor: grab;
user-select: none;
touch-action: pan-y;
}
.card-header.dragging {
cursor: grabbing;
}
.window-controls {
display: flex;
align-items: center;
gap: 6px;
}
.window-controls .control {
position: relative;
width: 12px;
height: 12px;
border-radius: 50%;
border: 1px solid rgba(0, 0, 0, 0.08);
background: #ccc;
cursor: pointer;
outline: none;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
}
.window-controls .control.close {
background: #ff5f57;
border-color: #e0443e;
}
.window-controls .control.minimize {
background: #febb2e;
border-color: #dea123;
}
.window-controls .control.maximize {
background: #28c840;
border-color: #1aab2c;
}
.window-controls .control::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
transition: opacity 0.2s ease;
}
.card-header:hover .window-controls .control::after {
opacity: 0.8;
}
.window-controls .control.close::after {
content: '×';
width: auto;
height: auto;
background: none;
font-size: 10px;
line-height: 1;
font-weight: 700;
color: rgba(0, 0, 0, 0.7);
}
.window-controls .control.minimize::after {
width: 6px;
height: 2px;
background: rgba(0, 0, 0, 0.6);
}
.window-controls .control.maximize::after {
width: 6px;
height: 6px;
background: linear-gradient(
45deg,
rgba(0, 0, 0, 0.6) 0%,
rgba(0, 0, 0, 0.6) 45%,
transparent 45%,
transparent 55%,
rgba(0, 0, 0, 0.6) 55%,
rgba(0, 0, 0, 0.6) 100%
);
}
.card-title {
font-size: 13px;
font-weight: 600;
color: rgba(0, 0, 0, 0.55);
padding-left: 10px;
flex: 1;
}
.card-body {
padding: 16px;
font-size: 16px;
line-height: 1.4;
font-weight: 600;
color: rgba(0, 0, 0, 0.72);
word-break: break-word;
overflow-wrap: anywhere;
white-space: normal;
}
.card.maximized {
display: flex;
flex-direction: column;
}
.card.maximized .card-title {
display: none;
}
.card.maximized .card-body {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
text-align: center;
padding: clamp(32px, min(10vw, 10vh), 128px);
padding-top: clamp(72px, min(14vw, 14vh), 192px);
font-size: clamp(48px, min(18vw, 18vh), 200px);
line-height: 1.05;
}
@media (max-width: 768px) {
.card {
width: 180px;
border-radius: 10px;
}
.card-body {
padding: 14px;
font-size: 14px;
}
.card-title {
font-size: 12px;
}
}
</style>
</head>
<body>
<div id="board"></div>
<script>
const board = document.getElementById('board')
const messages = [
'保持好心情',
'多喝水哦',
'今天辛苦啦',
'早点休息',
'记得吃水果',
'加油,你可以的',
'祝你顺利',
'保持微笑呀',
'愿所有烦恼都消失',
'期待下一次见面',
'梦想总会实现',
'天气冷了,多穿衣服',
'记得给自己放松',
'每天都要元气满满',
'今天也要好好爱自己',
'适当休息一下'
]
const colors = [
'#ffe0e3',
'#c7f0ff',
'#ffd8a8',
'#d9f2d9',
'#e5d7ff',
'#f9f7d9',
'#d2f0f8',
'#ffd4f5'
]
const cardStates = new WeakMap()
// Reserve a very high层级给全屏卡片,避免被后续元素覆盖
const MAXIMIZED_LAYER = 1000000
let activeMaximizedCard = null
const pointerMediaQuery = window.matchMedia('(pointer: coarse)')
let isMobile =
pointerMediaQuery.matches || window.innerWidth <= 768
let maxCards = isMobile ? 120 : 180 // 限制 DOM 节点数量,减轻移动端压力
const initialCardCount = isMobile ? 18 : 30
let spawnInterval = isMobile ? 700 : 400
let zIndexCursor = 200
let spawnTimer = null
document.body.classList.toggle('is-mobile', isMobile)
function randomFrom(array) {
return array[Math.floor(Math.random() * array.length)]
}
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max)
}
function applyTransform(card, state) {
const scale = state.scale ?? 1
const angle = state.angle ?? 0
card.style.transform = `scale(${scale}) rotate(${angle}deg)`
}
function bringToFront(card) {
if (card === activeMaximizedCard) {
card.style.zIndex = MAXIMIZED_LAYER
return
}
zIndexCursor += 1
if (activeMaximizedCard && zIndexCursor >= MAXIMIZED_LAYER) {
zIndexCursor = MAXIMIZED_LAYER - 1
}
card.style.zIndex = zIndexCursor
}
function updateBodyMaximizedState() {
document.body.classList.toggle(
'has-maximized-card',
Boolean(activeMaximizedCard)
)
}
function scheduleNextSpawn() {
clearTimeout(spawnTimer)
spawnTimer = setTimeout(() => {
if (!document.hidden) {
createCard()
}
scheduleNextSpawn()
}, spawnInterval)
}
function syncMobileMode() {
const nextIsMobile =
pointerMediaQuery.matches || window.innerWidth <= 768
if (nextIsMobile === isMobile) return
isMobile = nextIsMobile
maxCards = isMobile ? 120 : 180
spawnInterval = isMobile ? 700 : 400
document.body.classList.toggle('is-mobile', isMobile)
scheduleNextSpawn()
}
function handleBoardClick(event) {
const control = event.target.closest('.control')
if (!control) return
const card = control.closest('.card')
if (!card || !board.contains(card)) return
event.preventDefault()
if (control.classList.contains('close')) {
closeCard(card)
} else if (control.classList.contains('minimize')) {
minimizeCard(card)
} else if (control.classList.contains('maximize')) {
toggleMaximize(card)
}
}
function handleBoardPointerDown(event) {
const card = event.target.closest('.card')
if (!card || !board.contains(card)) return
const control = event.target.closest('.control')
const header = event.target.closest('.card-header')
const pointerType = event.pointerType || 'mouse'
const isPrimaryPointer = event.isPrimary !== false
if (
header &&
!control &&
pointerType !== 'touch' &&
isPrimaryPointer
) {
startDrag(event, card)
return
}
bringToFront(card)
}
function handleBoardDoubleClick(event) {
const header = event.target.closest('.card-header')
if (!header || event.target.closest('.control')) return
const card = header.closest('.card')
if (!card || !board.contains(card)) return
toggleMaximize(card)
}
board.addEventListener('click', handleBoardClick)
board.addEventListener('pointerdown', handleBoardPointerDown)
board.addEventListener('dblclick', handleBoardDoubleClick)
function closeCard(card) {
const state = cardStates.get(card)
if (!state || state.closing) return
if (card === activeMaximizedCard) {
activeMaximizedCard = null
updateBodyMaximizedState()
}
state.closing = true
state.scale = 0.1
card.style.opacity = '0'
applyTransform(card, state)
const handleTransitionEnd = event => {
if (event.propertyName === 'opacity') {
card.removeEventListener('transitionend', handleTransitionEnd)
card.remove()
}
}
card.addEventListener('transitionend', handleTransitionEnd)
}
function minimizeCard(card) {
const state = cardStates.get(card)
if (!state || state.closing) return
// 最小化动画:缩小并淡出到底部,结束时移除节点释放内存
const runMinimize = () => {
state.closing = true
bringToFront(card)
const bottom = Math.max(window.innerHeight - 24, 0)
const targetLeft = clamp(
state.left,
16,
Math.max(window.innerWidth - card.offsetWidth - 16, 16)
)
state.left = targetLeft
state.top = bottom
state.scale = 0.1
state.angle = 0
card.style.left = `${targetLeft}px`
card.style.top = `${bottom}px`
card.style.opacity = '0.35'
applyTransform(card, state)
const handleTransitionEnd = event => {
if (event.propertyName === 'transform') {
card.removeEventListener('transitionend', handleTransitionEnd)
card.remove()
}
}
card.addEventListener('transitionend', handleTransitionEnd)
}
if (state.maximized) {
restoreFromMaximize(card, state)
requestAnimationFrame(() => {
requestAnimationFrame(runMinimize)
})
return
}
runMinimize()
}
function toggleMaximize(card) {
const state = cardStates.get(card)
if (!state || state.closing) return
if (state.maximized) {
restoreFromMaximize(card, state)
} else {
maximizeCard(card, state)
}
}
function maximizeCard(card, state) {
if (activeMaximizedCard && activeMaximizedCard !== card) {
const activeState = cardStates.get(activeMaximizedCard)
if (activeState) {
restoreFromMaximize(activeMaximizedCard, activeState)
}
}
state.beforeMaximize = {
left: state.left,
top: state.top,
scale: state.scale ?? 1,
angle: state.angle ?? 0,
width: card.offsetWidth,
height: card.offsetHeight,
inlinePosition: card.style.position
}
card.classList.add('maximized')
card.style.position = 'fixed'
card.style.left = '0px'
card.style.top = '0px'
card.style.width = '100vw'
card.style.height = '100dvh'
card.style.borderRadius = '0'
state.left = 0
state.top = 0
state.scale = 1
state.angle = 0
applyTransform(card, state)
activeMaximizedCard = card
bringToFront(card)
state.maximized = true
updateBodyMaximizedState()
}
function restoreFromMaximize(card, state) {
const previous = state.beforeMaximize
if (!previous) return
card.classList.remove('maximized')
card.style.position = previous.inlinePosition || 'absolute'
card.style.left = `${previous.left}px`
card.style.top = `${previous.top}px`
card.style.width = `${previous.width}px`
card.style.height = `${previous.height}px`
card.style.borderRadius = '12px'
state.left = previous.left
state.top = previous.top
state.scale = previous.scale ?? 1
state.angle = previous.angle ?? state.angle ?? 0
applyTransform(card, state)
state.maximized = false
if (activeMaximizedCard === card) {
activeMaximizedCard = null
updateBodyMaximizedState()
}
bringToFront(card)
setTimeout(() => {
if (!state.maximized) {
card.style.width = ''
card.style.height = ''
card.style.borderRadius = ''
if (previous.inlinePosition) {
card.style.position = previous.inlinePosition
} else {
card.style.position = ''
}
state.beforeMaximize = null
}
}, 360)
}
function startDrag(event, card) {
const control = event.target.closest('.control')
if (control) return
const state = cardStates.get(card)
if (!state || state.closing || state.maximized) return
// 鼠标拖拽使用 rAF 节流,避免频繁触发布局计算
event.preventDefault()
bringToFront(card)
const header = card.querySelector('.card-header')
card.classList.add('dragging')
header.classList.add('dragging')
state.dragging = true
state.dragOffsetX = event.clientX - state.left
state.dragOffsetY = event.clientY - state.top
let dragFrame = null
let pendingLeft = state.left
let pendingTop = state.top
const commitDrag = () => {
dragFrame = null
const maxLeft = Math.max(window.innerWidth - card.offsetWidth, 0)
const maxTop = Math.max(window.innerHeight - card.offsetHeight, 0)
state.left = clamp(pendingLeft, -card.offsetWidth * 0.4, maxLeft)
state.top = clamp(pendingTop, -card.offsetHeight * 0.4, maxTop)
card.style.left = `${state.left}px`
card.style.top = `${state.top}px`
}
const handlePointerMove = moveEvent => {
if (!state.dragging) return
pendingLeft = moveEvent.clientX - state.dragOffsetX
pendingTop = moveEvent.clientY - state.dragOffsetY
if (dragFrame === null) {
dragFrame = requestAnimationFrame(commitDrag)
}
}
const handlePointerUp = () => {
state.dragging = false
card.classList.remove('dragging')
header.classList.remove('dragging')
if (dragFrame !== null) {
cancelAnimationFrame(dragFrame)
commitDrag()
}
document.removeEventListener('pointermove', handlePointerMove)
document.removeEventListener('pointerup', handlePointerUp)
}
document.addEventListener('pointermove', handlePointerMove)
document.addEventListener('pointerup', handlePointerUp)
}
function createCard() {
const card = document.createElement('div')
card.className = 'card'
const color = randomFrom(colors)
const angleRange = isMobile ? 6 : 10
const angle = (Math.random() - 0.5) * angleRange
const entryScale = isMobile ? 0.8 : 0.65
const cardWidth = isMobile ? 180 : 220
const cardHeight = isMobile ? 130 : 140
const horizontalMargin = isMobile ? 12 : 16
const verticalMargin = isMobile ? 12 : 20
const left =
horizontalMargin +
Math.random() *
Math.max(window.innerWidth - cardWidth - horizontalMargin * 2, 0)
const top =
verticalMargin +
Math.random() *
Math.max(window.innerHeight - cardHeight - verticalMargin * 2, 0)
card.style.background = color
card.style.left = `${left}px`
card.style.top = `${top}px`
card.style.opacity = '0'
if (activeMaximizedCard && zIndexCursor >= MAXIMIZED_LAYER - 2) {
zIndexCursor = MAXIMIZED_LAYER - 2
}
card.style.zIndex = ++zIndexCursor
card.innerHTML = `
<div class="card-header">
<div class="window-controls">
<button class="control close" type="button" aria-label="关闭"></button>
<button class="control minimize" type="button" aria-label="最小化"></button>
<button class="control maximize" type="button" aria-label="最大化"></button>
</div>
<div class="card-title">温馨提示</div>
</div>
<div class="card-body">${randomFrom(messages)}</div>
`
const state = {
angle,
scale: entryScale,
left,
top,
maximized: false,
closing: false
}
cardStates.set(card, state)
applyTransform(card, state)
board.appendChild(card)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
state.scale = 1
applyTransform(card, state)
card.style.opacity = '1'
})
})
if (board.children.length > maxCards) {
const oldest = board.firstElementChild
if (oldest && oldest !== card) {
oldest.remove()
}
}
}
for (let i = 0; i < initialCardCount; i++) {
setTimeout(createCard, i * (isMobile ? 60 : 40))
}
scheduleNextSpawn()
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
scheduleNextSpawn()
}
})
if (typeof pointerMediaQuery.addEventListener === 'function') {
pointerMediaQuery.addEventListener('change', syncMobileMode)
} else if (typeof pointerMediaQuery.addListener === 'function') {
pointerMediaQuery.addListener(syncMobileMode)
}
window.addEventListener('resize', syncMobileMode)
</script>
</body>
</html>