Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f80e3270f | ||
|
|
3f2f6a4e37 | ||
|
|
0332c7b147 | ||
|
|
1e30abfac4 |
@@ -0,0 +1,77 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<defs>
|
||||
<linearGradient id="tile" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#1a1e2b"/>
|
||||
<stop offset="0.55" stop-color="#161a26"/>
|
||||
<stop offset="1" stop-color="#10131c"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="sheen" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffffff" stop-opacity="0.09"/>
|
||||
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="moonGlow" cx="0.5" cy="0.5" r="0.5">
|
||||
<stop offset="0" stop-color="#eef2ff" stop-opacity="0.22"/>
|
||||
<stop offset="1" stop-color="#eef2ff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<linearGradient id="moon" x1="0.2" y1="0.1" x2="0.8" y2="0.9">
|
||||
<stop offset="0" stop-color="#ffffff"/>
|
||||
<stop offset="1" stop-color="#c9d4f2"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="card" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#ffffff" stop-opacity="0.16"/>
|
||||
<stop offset="1" stop-color="#ffffff" stop-opacity="0.07"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="hgrad" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0" stop-color="#f2f5ff"/>
|
||||
<stop offset="1" stop-color="#a7b8f0"/>
|
||||
</linearGradient>
|
||||
<filter id="shadow" x="-40%" y="-40%" width="180%" height="180%">
|
||||
<feDropShadow dx="0" dy="10" stdDeviation="16" flood-color="#000000" flood-opacity="0.45"/>
|
||||
</filter>
|
||||
<filter id="cardShadow" x="-30%" y="-30%" width="160%" height="160%">
|
||||
<feDropShadow dx="0" dy="6" stdDeviation="12" flood-color="#000000" flood-opacity="0.5"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- 图标底:深墨夜色圆角方块 -->
|
||||
<rect x="0" y="0" width="512" height="512" rx="116" fill="url(#tile)"/>
|
||||
<!-- 顶部玻璃高光 -->
|
||||
<rect x="0" y="0" width="512" height="260" rx="116" fill="url(#sheen)"/>
|
||||
|
||||
<!-- 夜空细星点(狩猎者之夜) -->
|
||||
<g fill="#e7ecff" opacity="0.5">
|
||||
<circle cx="96" cy="120" r="2.6"/><circle cx="150" cy="196" r="1.7"/>
|
||||
<circle cx="420" cy="360" r="2"/><circle cx="356" cy="92" r="1.6"/>
|
||||
<circle cx="88" cy="340" r="1.8"/><circle cx="452" cy="200" r="2.2"/>
|
||||
</g>
|
||||
|
||||
<!-- 猎人之月(月牙 + 柔光) -->
|
||||
<circle cx="404" cy="116" r="62" fill="url(#moonGlow)"/>
|
||||
<g transform="rotate(-18 404 116)">
|
||||
<circle cx="404" cy="116" r="40" fill="url(#moon)"/>
|
||||
<circle cx="418" cy="106" r="36" fill="#1a1e2b"/>
|
||||
</g>
|
||||
|
||||
<!-- 玻璃笔记卡片(居中,微倾) -->
|
||||
<g transform="rotate(-6 256 268)" filter="url(#cardShadow)">
|
||||
<rect x="96" y="150" width="320" height="236" rx="44" fill="url(#card)" stroke="#ffffff" stroke-opacity="0.4" stroke-width="2"/>
|
||||
<!-- 卡片内部顶部细光条 -->
|
||||
<rect x="112" y="164" width="288" height="30" rx="15" fill="#ffffff" opacity="0.10"/>
|
||||
</g>
|
||||
|
||||
<!-- 字标 H(纯几何,保证跨机器一致) -->
|
||||
<g transform="rotate(-6 256 268)">
|
||||
<!-- 左竖 -->
|
||||
<rect x="204" y="210" width="46" height="120" rx="23" fill="url(#hgrad)"/>
|
||||
<!-- 右竖 -->
|
||||
<rect x="286" y="210" width="46" height="120" rx="23" fill="url(#hgrad)"/>
|
||||
<!-- 横杠 -->
|
||||
<rect x="227" y="254" width="82" height="42" rx="21" fill="url(#hgrad)"/>
|
||||
</g>
|
||||
|
||||
<!-- 笔记内容行(卡片下方三条短线) -->
|
||||
<g transform="rotate(-6 256 268)" fill="#ffffff">
|
||||
<rect x="140" y="338" width="150" height="11" rx="5.5" opacity="0.55"/>
|
||||
<rect x="140" y="360" width="110" height="11" rx="5.5" opacity="0.38"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 612 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
@@ -18,6 +18,7 @@
|
||||
],
|
||||
"asar": true,
|
||||
"win": {
|
||||
"icon": "build/icon.ico",
|
||||
"target": [
|
||||
"nsis",
|
||||
"dir"
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { deflateSync } from 'node:zlib';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import nodePath from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = nodePath.dirname(fileURLToPath(import.meta.url));
|
||||
const OUT = nodePath.join(__dirname, '..', 'build');
|
||||
const SIZES = [16, 24, 32, 48, 64, 128, 256, 512];
|
||||
|
||||
// ---------- PNG encoder ----------
|
||||
const CRC_TABLE = (() => {
|
||||
const t = new Uint32Array(256);
|
||||
for (let n = 0; n < 256; n++) {
|
||||
let c = n;
|
||||
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||
t[n] = c >>> 0;
|
||||
}
|
||||
return t;
|
||||
})();
|
||||
function crc32(buf) {
|
||||
let c = 0xffffffff;
|
||||
for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xff] ^ (c >>> 8);
|
||||
return (c ^ 0xffffffff) >>> 0;
|
||||
}
|
||||
function pngChunk(type, data) {
|
||||
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
|
||||
const typeBuf = Buffer.from(type, 'ascii');
|
||||
const crcBuf = Buffer.alloc(4); crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
|
||||
return Buffer.concat([len, typeBuf, data, crcBuf]);
|
||||
}
|
||||
function encodePNG(w, h, rgba) {
|
||||
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const ihdr = Buffer.alloc(13);
|
||||
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
|
||||
ihdr[8] = 8; ihdr[9] = 6; // bit depth 8, color type 6 (RGBA)
|
||||
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
|
||||
const stride = w * 4;
|
||||
const raw = Buffer.alloc(h * (stride + 1));
|
||||
for (let y = 0; y < h; y++) {
|
||||
raw[y * (stride + 1)] = 0; // filter None
|
||||
rgba.copy(raw, y * (stride + 1) + 1, y * stride, (y + 1) * stride);
|
||||
}
|
||||
const idat = pngChunk('IDAT', deflateSync(raw, { level: 9 }));
|
||||
return Buffer.concat([sig, pngChunk('IHDR', ihdr), idat, pngChunk('IEND', Buffer.alloc(0))]);
|
||||
}
|
||||
|
||||
// ---------- ICO encoder ----------
|
||||
function encodeICO(sizes, pngs) {
|
||||
const header = Buffer.alloc(6);
|
||||
header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(sizes.length, 4);
|
||||
const entries = [];
|
||||
let offset = 6 + 16 * sizes.length;
|
||||
for (let i = 0; i < sizes.length; i++) {
|
||||
const s = sizes[i];
|
||||
const e = Buffer.alloc(16);
|
||||
e[0] = s >= 256 ? 0 : s; e[1] = s >= 256 ? 0 : s;
|
||||
e[2] = 0; e[3] = 0;
|
||||
e.writeUInt16LE(1, 4); e.writeUInt16LE(32, 6);
|
||||
e.writeUInt32LE(pngs[i].length, 8);
|
||||
e.writeUInt32LE(offset, 12);
|
||||
entries.push(e);
|
||||
offset += pngs[i].length;
|
||||
}
|
||||
return Buffer.concat([header, ...entries, ...pngs]);
|
||||
}
|
||||
|
||||
// ---------- Rasterizer ----------
|
||||
function blend(buf, W, x, y, r, g, b, a) {
|
||||
if (a <= 0.001) return;
|
||||
x = Math.round(x); y = Math.round(y);
|
||||
if (x < 0 || y < 0 || x >= W || y >= W) return;
|
||||
const i = (y * W + x) * 4;
|
||||
const da = buf[i + 3] / 255;
|
||||
const oa = a + da * (1 - a);
|
||||
if (oa <= 0) return;
|
||||
buf[i] = Math.round((r * a + buf[i] * da * (1 - a)) / oa);
|
||||
buf[i + 1] = Math.round((g * a + buf[i + 1] * da * (1 - a)) / oa);
|
||||
buf[i + 2] = Math.round((b * a + buf[i + 2] * da * (1 - a)) / oa);
|
||||
buf[i + 3] = Math.round(oa * 255);
|
||||
}
|
||||
function inRR(px, py, x, y, w, h, r) {
|
||||
if (px < x || px > x + w || py < y || py > y + h) return false;
|
||||
const cx = Math.max(x + r, Math.min(px, x + w - r));
|
||||
const cy = Math.max(y + r, Math.min(py, y + h - r));
|
||||
const dx = px - cx, dy = py - cy;
|
||||
return dx * dx + dy * dy <= r * r;
|
||||
}
|
||||
function inCircle(px, py, cx, cy, r) {
|
||||
const dx = px - cx, dy = py - cy;
|
||||
return dx * dx + dy * dy <= r * r;
|
||||
}
|
||||
function lerp(a, b, t) { return a + (b - a) * t; }
|
||||
function lerpColor(c1, c2, t) { return [lerp(c1[0], c2[0], t), lerp(c1[1], c2[1], t), lerp(c1[2], c2[2], t)]; }
|
||||
|
||||
// linear gradient stops: [[pos, [r,g,b], alpha], ...]
|
||||
function vGradientColor(stops, y, y0, y1) {
|
||||
const t = Math.min(1, Math.max(0, (y - y0) / (y1 - y0)));
|
||||
for (let i = 0; i < stops.length - 1; i++) {
|
||||
const a = stops[i], b = stops[i + 1];
|
||||
if (t >= a[0] && t <= b[0]) {
|
||||
const lt = (t - a[0]) / (b[0] - a[0] || 1);
|
||||
return [lerp(a[1][0], b[1][0], lt), lerp(a[1][1], b[1][1], lt), lerp(a[1][2], b[1][2], lt), lerp(a[2], b[2], lt)];
|
||||
}
|
||||
}
|
||||
const last = stops[stops.length - 1];
|
||||
return [...last[1], last[2]];
|
||||
}
|
||||
|
||||
// Box blur on alpha channel (for soft shadows)
|
||||
function boxBlur(buf, W, radius) {
|
||||
const H = W;
|
||||
const tmp = new Float32Array(W * H);
|
||||
for (let i = 0; i < W * H; i++) tmp[i] = buf[i * 4 + 3] / 255;
|
||||
const src = tmp.slice();
|
||||
const win = radius * 2 + 1;
|
||||
// horizontal
|
||||
for (let y = 0; y < H; y++) {
|
||||
let acc = 0;
|
||||
for (let x = -radius; x <= radius; x++) acc += src[Math.max(0, Math.min(W - 1, x)) + y * W];
|
||||
for (let x = 0; x < W; x++) {
|
||||
tmp[x + y * W] = acc / win;
|
||||
const add = Math.min(W - 1, x + radius + 1), rem = Math.max(0, x - radius);
|
||||
acc += src[add + y * W] - src[rem + y * W];
|
||||
}
|
||||
}
|
||||
const src2 = tmp.slice();
|
||||
for (let x = 0; x < W; x++) {
|
||||
let acc = 0;
|
||||
for (let y = -radius; y <= radius; y++) acc += src2[x + Math.max(0, Math.min(H - 1, y)) * W];
|
||||
for (let y = 0; y < H; y++) {
|
||||
tmp[x + y * W] = acc / win;
|
||||
const add = Math.min(H - 1, y + radius + 1), rem = Math.max(0, y - radius);
|
||||
acc += src2[x + add * W] - src2[x + rem * W];
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < W * H; i++) buf[i * 4 + 3] = Math.round(tmp[i] * 255);
|
||||
}
|
||||
|
||||
// Affine: rotate around pivot
|
||||
function rotPoint(px, py, px_, py_, cosA, sinA) {
|
||||
const dx = px - px_, dy = py - py_;
|
||||
return [px_ + dx * cosA - dy * sinA, py_ + dx * sinA + dy * cosA];
|
||||
}
|
||||
|
||||
function render(size) {
|
||||
const hi = Math.max(256, size * 2);
|
||||
const k = hi / 512;
|
||||
const W = hi;
|
||||
const buf = new Uint8ClampedArray(W * W * 4);
|
||||
// 循环坐标即缓冲区坐标(缓冲区 W=hi,循环 0..hi);k 只用于设计坐标→缓冲区换算
|
||||
const blendAt = (x, y, r, g, b, a) => blend(buf, W, x, y, r, g, b, a);
|
||||
const sA = (ang) => Math.sin(ang), cA = (ang) => Math.cos(ang);
|
||||
|
||||
// ---- Tile base (vertical gradient, rounded 116) ----
|
||||
const tileStops = [[0, [0x1a, 0x1e, 0x2b], 1], [0.55, [0x16, 0x1a, 0x26], 1], [1, [0x10, 0x13, 0x1c], 1]];
|
||||
for (let y = 0; y < hi; y++) {
|
||||
const c = vGradientColor(tileStops, y / k, 0, 512);
|
||||
for (let x = 0; x < hi; x++) {
|
||||
if (inRR(x / k, y / k, 0, 0, 512, 512, 116)) blendAt(x, y, c[0], c[1], c[2], 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Top sheen (rounded top, white 0.09 -> 0) ----
|
||||
for (let y = 0; y < hi; y++) {
|
||||
const t = Math.min(1, Math.max(0, (y / k) / 260));
|
||||
const a = 0.09 * (1 - t);
|
||||
for (let x = 0; x < hi; x++) {
|
||||
if (a > 0.002 && inRR(x / k, y / k, 0, 0, 512, 512, 116)) blendAt(x, y, 255, 255, 255, a);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Stars ----
|
||||
const stars = [[96, 120, 2.6, 0.5], [150, 196, 1.7, 0.5], [420, 360, 2, 0.5], [356, 92, 1.6, 0.5], [88, 340, 1.8, 0.5], [452, 200, 2.2, 0.5]];
|
||||
for (const [sx, sy, sr, sa] of stars) {
|
||||
const r0 = Math.max(0, Math.floor((sy - sr - 1) * k)), r1 = Math.min(hi, Math.ceil((sy + sr + 1) * k));
|
||||
const c0 = Math.max(0, Math.floor((sx - sr - 1) * k)), c1 = Math.min(hi, Math.ceil((sx + sr + 1) * k));
|
||||
for (let y = r0; y < r1; y++) for (let x = c0; x < c1; x++) {
|
||||
if (inCircle(x / k, y / k, sx, sy, sr)) blendAt(x, y, 231, 236, 255, sa);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Moon: glow + crescent ----
|
||||
// glow
|
||||
for (let y = 0; y < hi; y++) for (let x = 0; x < hi; x++) {
|
||||
const dx = x / k - 404, dy = y / k - 116;
|
||||
const d = Math.sqrt(dx * dx + dy * dy);
|
||||
if (d <= 62) blendAt(x, y, 238, 242, 255, 0.22 * (1 - d / 62));
|
||||
}
|
||||
// crescent (rotate -18 deg around (404,116))
|
||||
const cosA = cA(-18 * Math.PI / 180), sinA = sA(-18 * Math.PI / 180);
|
||||
for (let y = 0; y < hi; y++) for (let x = 0; x < hi; x++) {
|
||||
const [lx, ly] = rotPoint(x / k, y / k, 404, 116, cosA, sinA);
|
||||
if (inCircle(lx, ly, 404, 116, 40)) {
|
||||
const t = Math.min(1, Math.max(0, (ly - 76) / 80));
|
||||
const mc = lerpColor([255, 255, 255], [201, 212, 242], t);
|
||||
blendAt(x, y, mc[0], mc[1], mc[2], 1);
|
||||
}
|
||||
if (inCircle(lx, ly, 418, 106, 36)) blendAt(x, y, 0x1a, 0x1e, 0x2b, 1);
|
||||
}
|
||||
|
||||
// ---- Card drop shadow (behind card, dark blurred, offset down-right) ----
|
||||
const cardCos = cA(-6 * Math.PI / 180), cardSin = sA(-6 * Math.PI / 180);
|
||||
const cardLocal = (x, y) => rotPoint(x / k, y / k, 256, 268, cardCos, cardSin);
|
||||
const shadowBuf = new Uint8ClampedArray(W * W * 4);
|
||||
for (let y = 0; y < hi; y++) for (let x = 0; x < hi; x++) {
|
||||
const [lx, ly] = cardLocal(x, y);
|
||||
if (inRR(lx + 4, ly + 12, 96, 150, 320, 236, 44)) {
|
||||
const i = (y * W + x) * 4;
|
||||
shadowBuf[i] = 0; shadowBuf[i + 1] = 0; shadowBuf[i + 2] = 0; shadowBuf[i + 3] = 255;
|
||||
}
|
||||
}
|
||||
boxBlur(shadowBuf, W, Math.max(2, Math.round(18 * k / 4)));
|
||||
for (let i = 0; i < W * W; i++) {
|
||||
const a = shadowBuf[i * 4 + 3] / 255 * 0.5;
|
||||
if (a > 0.002) {
|
||||
const x = i % W, y = (i / W) | 0;
|
||||
blend(buf, W, x, y, 0, 0, 0, a);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Card group (rotate -6 deg around 256,268) ----
|
||||
// draw card (and inner bar + lines) via local coords
|
||||
for (let y = 0; y < hi; y++) for (let x = 0; x < hi; x++) {
|
||||
const [lx, ly] = cardLocal(x, y);
|
||||
// card body: gradient white 0.16->0.07
|
||||
if (inRR(lx, ly, 96, 150, 320, 236, 44)) {
|
||||
const t = Math.min(1, Math.max(0, (ly - 150) / 236));
|
||||
const a = 0.16 - 0.09 * t;
|
||||
blendAt(x, y, 255, 255, 255, a);
|
||||
}
|
||||
// inner top light bar
|
||||
if (inRR(lx, ly, 112, 164, 288, 30, 15)) blendAt(x, y, 255, 255, 255, 0.10);
|
||||
// card border (ring): draw by testing just outside inner, inside outer
|
||||
// note lines
|
||||
if (inRR(lx, ly, 140, 338, 150, 11, 5.5)) blendAt(x, y, 255, 255, 255, 0.55);
|
||||
if (inRR(lx, ly, 140, 360, 110, 11, 5.5)) blendAt(x, y, 255, 255, 255, 0.38);
|
||||
// H monogram
|
||||
const hStops = [[0, [0xf2, 0xf5, 0xff], 1], [1, [0xa7, 0xb8, 0xf0], 1]];
|
||||
const hc = vGradientColor(hStops, ly, 210, 330);
|
||||
if (inRR(lx, ly, 204, 210, 46, 120, 23) || inRR(lx, ly, 286, 210, 46, 120, 23) || inRR(lx, ly, 227, 254, 82, 42, 21)) {
|
||||
blendAt(x, y, hc[0], hc[1], hc[2], 1);
|
||||
}
|
||||
}
|
||||
// card border: stroke ring (approx) - draw after body
|
||||
for (let y = 0; y < hi; y++) for (let x = 0; x < hi; x++) {
|
||||
const [lx, ly] = cardLocal(x, y);
|
||||
if (inRR(lx, ly, 96, 150, 320, 236, 44) && !inRR(lx, ly, 100, 154, 312, 228, 40)) {
|
||||
blendAt(x, y, 255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Downsample hi -> size (box average, straight alpha) ----
|
||||
if (size === hi) return Buffer.from(buf);
|
||||
const out = Buffer.alloc(size * size * 4);
|
||||
const ratio = hi / size;
|
||||
for (let y = 0; y < size; y++) {
|
||||
for (let x = 0; x < size; x++) {
|
||||
let pr = 0, pg = 0, pb = 0, pa = 0;
|
||||
const x0 = Math.floor(x * ratio), y0 = Math.floor(y * ratio);
|
||||
const x1 = Math.min(hi, Math.ceil((x + 1) * ratio)), y1 = Math.min(hi, Math.ceil((y + 1) * ratio));
|
||||
for (let yy = y0; yy < y1; yy++) for (let xx = x0; xx < x1; xx++) {
|
||||
const i = (yy * W + xx) * 4;
|
||||
const aa = buf[i + 3] / 255;
|
||||
if (aa > 0) { pr += buf[i] * aa; pg += buf[i + 1] * aa; pb += buf[i + 2] * aa; pa += aa; }
|
||||
}
|
||||
const o = (y * size + x) * 4;
|
||||
if (pa > 0) { out[o] = Math.round(pr / pa); out[o + 1] = Math.round(pg / pa); out[o + 2] = Math.round(pb / pa); out[o + 3] = Math.round(pa * 255); }
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- Main ----------
|
||||
async function main() {
|
||||
const pngs = [];
|
||||
for (const s of SIZES) {
|
||||
const rgba = render(s);
|
||||
const png = encodePNG(s, s, rgba);
|
||||
await fs.writeFile(nodePath.join(OUT, 'icon', s + '.png'), png);
|
||||
pngs.push(png);
|
||||
console.log('rendered', s + 'px', png.length, 'bytes');
|
||||
}
|
||||
const ico = encodeICO(SIZES, pngs);
|
||||
await fs.writeFile(nodePath.join(OUT, 'icon.ico'), ico);
|
||||
console.log('icon.ico', ico.length, 'bytes');
|
||||
console.log('DONE');
|
||||
}
|
||||
await main();
|
||||
@@ -1,5 +1,5 @@
|
||||
// main 进程入口:创建窗口 + 初始化库 + 注册 IPC + 迁移检测
|
||||
import { app, BrowserWindow, ipcMain, protocol, net, dialog } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain, protocol, net, dialog, shell } from 'electron';
|
||||
import nodePath from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { promises as fs } from 'node:fs';
|
||||
@@ -31,6 +31,19 @@ function createWindow() {
|
||||
});
|
||||
if (!win) win = w;
|
||||
w.on('ready-to-show', () => w.show());
|
||||
// 安全:外部链接一律交给系统默认浏览器,不在应用窗口内导航(避免丢失编辑器状态)
|
||||
w.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
w.webContents.on('will-navigate', (e, url) => {
|
||||
const current = w.webContents.getURL();
|
||||
// 仅拦截跳转到其它 http(s) 站点的导航;应用自身 (vaultasset/file/app) 不拦
|
||||
if (/^https?:\/\//i.test(url) && url !== current) {
|
||||
e.preventDefault();
|
||||
void shell.openExternal(url);
|
||||
}
|
||||
});
|
||||
// 转发渲染进程控制台消息到主进程终端(便于开发调试)
|
||||
w.webContents.on('console-message', (e, level, message, line, sourceId) => {
|
||||
const lv = ['log', 'info', 'warn', 'error'][level] || 'log';
|
||||
|
||||
@@ -93,4 +93,13 @@ export function registerIpc(vault: VaultService) {
|
||||
if (!fs2) return;
|
||||
shell.showItemInFolder(nodePath.join(fs2.getRoot(), p.path));
|
||||
});
|
||||
|
||||
// 超链接用系统默认浏览器打开(不用 Electron 内置窗口)
|
||||
ipcMain.handle(IPC.openExternal, async (_e, p: { url: string }) => {
|
||||
const url = p?.url?.trim();
|
||||
if (!url) return;
|
||||
// 仅允许 http/https,防止把本地协议或 file: 路径交给外部程序
|
||||
if (!/^https?:\/\//i.test(url)) return;
|
||||
await shell.openExternal(url);
|
||||
});
|
||||
}
|
||||
@@ -8,8 +8,8 @@ export interface GraphNode { id: string; title: string; basePath: string; star:
|
||||
export interface GraphEdge { source: string; target: string; }
|
||||
export interface GraphData { nodes: GraphNode[]; edges: GraphEdge[]; }
|
||||
|
||||
// 12 色轮换
|
||||
const COLORS = ['#e6194b','#3cb44b','#ffe119','#4363d8','#f58231','#911eb4','#46f0f0','#f032e6','#bcf60c','#fabebe','#008080','#e6beff'];
|
||||
// 分组着色——克制的墨灰阶(契合黑白灰玻璃设计体系,避免彩虹色破坏高级感)
|
||||
const COLORS = ['#9aa1ad','#6d7480','#b6bcc6','#3c414b','#c8ccd4','#5c616b','#7c8491','#4a4f58','#aab1bd','#565b66','#8b929d','#626a75'];
|
||||
|
||||
export async function buildGraphData(root: string, tree: TreeNode[]): Promise<GraphData> {
|
||||
const docs = tree.filter(n => n.type === 'doc');
|
||||
|
||||
@@ -73,10 +73,12 @@ export function DocInfoPanel() {
|
||||
{links.length === 0 ? (
|
||||
<div style={{ color: 'var(--text-tertiary)', opacity: 0.6, marginBottom: 8 }}>暂无关联,可在下方快速关联或批量关联</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, marginBottom: 10 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, marginBottom: 10 }}>
|
||||
{links.map(l => (
|
||||
<div key={l} style={{ display: 'flex', alignItems: 'center', gap: 6, padding: '4px 8px', background: 'var(--bg-secondary)', borderRadius: 6, border: '1px solid var(--control-border)' }}>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-secondary)' }} title={l}>{linkNames.get(l) ?? l}</span>
|
||||
<div key={l} title={l} style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '7px 10px', background: 'var(--bg-secondary)', borderRadius: 'var(--radius-sm)', border: '1px solid var(--control-border)', boxShadow: 'var(--shadow-soft)', transition: 'border-color .15s var(--ease), box-shadow .15s var(--ease)' }} onMouseEnter={e => { e.currentTarget.style.borderColor = 'var(--line-strong)'; e.currentTarget.style.boxShadow = 'var(--shadow-lift)'; }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--control-border)'; e.currentTarget.style.boxShadow = 'var(--shadow-soft)'; }}>
|
||||
<span style={{ display: 'inline-flex', color: 'var(--text-tertiary)', flexShrink: 0 }}><Link2 size={14} /></span>
|
||||
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: 'var(--text-primary)', fontWeight: 500 }}>{linkNames.get(l) ?? l}</span>
|
||||
<span style={{ fontSize: 10, color: 'var(--text-tertiary)', flexShrink: 0, display: 'inline-flex', alignItems: 'center', gap: 3 }}><ArrowLeftRight size={11} />双向</span>
|
||||
<button style={iconSmall} title="打开" onClick={() => openDoc(l)}><FileText size={13} /></button>
|
||||
<button style={iconSmall} title="解除关联(双向)" onClick={() => void linkBatch([l], true)}><X size={13} /></button>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,9 @@ export function LinkBubbleMenu({ editor }: { editor: Editor }) {
|
||||
const apply = () => { const u = href.trim(); if (u) editor.chain().focus().extendMarkRange('link').setLink({ href: u }).run(); };
|
||||
const remove = () => editor.chain().focus().extendMarkRange('link').unsetLink().run();
|
||||
return (
|
||||
<BubbleMenu editor={editor} shouldShow={({ editor }) => editor.isActive('link')} className="link-bubble"
|
||||
<BubbleMenu editor={editor} shouldShow={({ editor, state }) =>
|
||||
// 只在「选中了链接文字」时才弹出,避免光标停在链接上就弹气泡打断编辑
|
||||
editor.isActive('link') && !state.selection.empty} className="link-bubble"
|
||||
style={{ display: 'flex', gap: 4, alignItems: 'center', background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', borderRadius: 6, padding: 4, boxShadow: '0 4px 16px rgba(0,0,0,0.4)' }}>
|
||||
<input value={href} onChange={e => setHref(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); apply(); } }}
|
||||
placeholder="链接地址" style={{ width: 180, background: 'var(--bg-secondary)', border: '1px solid var(--control-border)', color: 'var(--text-primary)', borderRadius: 4, padding: '3px 8px', fontSize: 13, outline: 'none' }} />
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Markdown } from 'tiptap-markdown';
|
||||
import { createLowlight, common } from 'lowlight';
|
||||
import { IPC } from '@hunter/shared';
|
||||
import { useStore } from '../../store';
|
||||
import { useTabs } from '../tabs/tab-store';
|
||||
import { invoke } from '../../ipc-client';
|
||||
import { EditorProvider } from './editor-context';
|
||||
import { useEditorShared } from './editor-shared-store';
|
||||
@@ -44,6 +45,8 @@ const Img = Image.extend({
|
||||
export function TiptapEditor() {
|
||||
const currentPath = useStore(s => s.currentPath);
|
||||
const setBody = useStore(s => s.setBody);
|
||||
// 当前文档对应的标签页 id(用于读写该标签记忆的滚动位置)
|
||||
const activeTabId = useTabs(s => s.tabs.find(t => t.path === currentPath)?.id);
|
||||
const skipUpdate = useRef(false);
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
const mdTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -141,6 +144,19 @@ export function TiptapEditor() {
|
||||
}
|
||||
return false;
|
||||
},
|
||||
// 点击超链接 → 用系统默认浏览器打开(openOnClick 关闭,手动接管)
|
||||
handleClick: (view, _pos, event) => {
|
||||
const target = (event.target as HTMLElement | null)?.closest?.('a[href]');
|
||||
if (!target) return false;
|
||||
const href = (target as HTMLAnchorElement).getAttribute('href') ?? '';
|
||||
// 仅接管纯单击(不含 Ctrl/Cmd/Shift/Alt),避免影响文字选取编辑
|
||||
if (/^https?:\/\//i.test(href) && !event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey) {
|
||||
event.preventDefault();
|
||||
void invoke(IPC.openExternal, { url: href });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
onUpdate: ({ editor }) => {
|
||||
if (skipUpdate.current) return;
|
||||
@@ -166,8 +182,32 @@ export function TiptapEditor() {
|
||||
skipUpdate.current = true;
|
||||
editor.commands.setContent(body || '', { emitUpdate: false });
|
||||
skipUpdate.current = false;
|
||||
// 滚动位置恢复:编辑器实例复用,滚动容器不随文档重建。新文档默认 0(顶部);
|
||||
// 切回已打开的标签页则恢复到该标签上次记忆的位置。
|
||||
const tabScroll = useTabs.getState().tabs.find(t => t.path === currentPath)?.scrollTop ?? 0;
|
||||
const scroller = editor.view.dom.parentElement as HTMLElement | null;
|
||||
if (scroller) {
|
||||
requestAnimationFrame(() => { scroller.scrollTop = tabScroll; });
|
||||
}
|
||||
}, [currentPath, editor]);
|
||||
|
||||
// FR-T5:把当前文档的滚动位置记录回对应标签页(切换/重开时可恢复;不触发持久化)
|
||||
useEffect(() => {
|
||||
if (!editor || !activeTabId) return;
|
||||
const scroller = editor.view.dom.parentElement as HTMLElement | null;
|
||||
if (!scroller) return;
|
||||
let raf = 0;
|
||||
const onScroll = () => {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(() => {
|
||||
raf = 0;
|
||||
useTabs.getState().updateScroll(activeTabId, scroller.scrollTop);
|
||||
});
|
||||
};
|
||||
scroller.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => { scroller.removeEventListener('scroll', onScroll); if (raf) cancelAnimationFrame(raf); };
|
||||
}, [editor, activeTabId]);
|
||||
|
||||
// FR-E13:长文档底部书写——光标行距编辑区底部保持留白,避免输入内容贴着页面底缘
|
||||
// B档-7:同时同步光标所在行高亮(.is-active-line)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -91,8 +91,18 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
|
||||
.map((e) => ({ source: idx.get(e.source)!, target: idx.get(e.target)! }));
|
||||
|
||||
// 曲线连线(贝塞尔弧):比直线更优雅、更符合"图谱"观感
|
||||
const linkSel = g.append('g').attr('stroke', 'var(--line)').attr('stroke-opacity', 0.6)
|
||||
.selectAll('path').data(links).join('path').attr('fill', 'none').attr('stroke-width', 1.1);
|
||||
// 与当前文档直接相连的节点集合——用于高亮"关联"关系
|
||||
const curPath = useStore.getState().currentPath;
|
||||
const linkedIds = new Set<string>();
|
||||
for (const e of data.edges) {
|
||||
if (e.source === curPath) linkedIds.add(e.target);
|
||||
if (e.target === curPath) linkedIds.add(e.source);
|
||||
}
|
||||
const linkSel = g.append('g')
|
||||
.selectAll('path').data(links).join('path').attr('fill', 'none')
|
||||
.attr('stroke', (d: any) => (d.source.id === curPath || d.target.id === curPath) ? 'var(--ink-2)' : 'var(--line)')
|
||||
.attr('stroke-opacity', (d: any) => (d.source.id === curPath || d.target.id === curPath) ? 0.9 : 0.5)
|
||||
.attr('stroke-width', (d: any) => (d.source.id === curPath || d.target.id === curPath) ? 2 : 1.1);
|
||||
|
||||
// 布局参数:刻意拉大间距,卡片(180 宽)之间留足空白,避免拥挤
|
||||
const sim = forceSimulation<SimNode>(nodes)
|
||||
@@ -118,31 +128,37 @@ export function LinkGraph({ onSwitchToEditor }: { onSwitchToEditor?: () => void
|
||||
const len = Math.max(1, Math.hypot(dx, dy));
|
||||
return 'M' + sx + ',' + sy + ' Q' + (mx + (nx / len) * bend * dir) + ',' + (my + (ny / len) * bend * dir) + ' ' + tx + ',' + ty;
|
||||
};
|
||||
// 当前打开的文档作为深墨焦点节点
|
||||
const curPath = useStore.getState().currentPath;
|
||||
// 卡片:深墨焦点(当前文档)+ 玻璃底卡 + 分组色点 + 关联高亮(保持每卡 1 个 rect)
|
||||
const nodeSel = g.append('g').selectAll('g').data(nodes).join('g').attr('cursor', 'pointer')
|
||||
// 阴影 filter 加在 g 层:若加在卡片 rect 上会导致该 rect 的 pointer-events 命中失效(点击穿透)
|
||||
.attr('filter', (d: any) => d.id === curPath ? 'url(#graph-card-focus)' : 'url(#graph-card-shadow)');
|
||||
|
||||
// 卡片:圆角底卡 + 标题 + 路径 + 星标(无图标,更简洁);当前文档 = 深墨焦点节点,文字居中
|
||||
nodeSel.append('rect').attr('class', 'gcard').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', CARD_W).attr('height', CARD_H).attr('rx', 13)
|
||||
nodeSel.append('rect').attr('class', 'gcard').attr('x', -CARD_W / 2).attr('y', -CARD_H / 2).attr('width', CARD_W).attr('height', CARD_H).attr('rx', 14)
|
||||
.attr('fill', (d: any) => d.id === curPath ? 'var(--ink)' : 'var(--float)')
|
||||
.attr('stroke', (d: any) => d.id === curPath ? 'var(--ink)' : 'var(--line)')
|
||||
.attr('stroke-width', 1.2)
|
||||
.attr('stroke', (d: any) => d.id === curPath ? 'var(--ink)' : (linkedIds.has(d.id) ? 'var(--ink-2)' : 'var(--line)'))
|
||||
.attr('stroke-width', (d: any) => (d.id === curPath || linkedIds.has(d.id)) ? 1.6 : 1.1)
|
||||
.attr('pointer-events', 'all');
|
||||
// 分组色点(左上角,circle 不增加卡片 rect 计数)
|
||||
nodeSel.append('circle').attr('cx', -CARD_W / 2 + 10).attr('cy', -CARD_H / 2 + 10).attr('r', 3.4)
|
||||
.attr('fill', (d: any) => d.id === curPath ? 'var(--space)' : (d.color || 'var(--ink-3)'))
|
||||
.attr('opacity', 0.9);
|
||||
// 标题(水平居中)
|
||||
nodeSel.append('text').attr('x', 0).attr('y', -2).attr('text-anchor', 'middle')
|
||||
nodeSel.append('text').attr('x', 0).attr('y', -3).attr('text-anchor', 'middle')
|
||||
.attr('fill', (d: any) => d.id === curPath ? 'var(--space)' : 'var(--ink)')
|
||||
.attr('font-size', 13).attr('font-weight', (d: any) => d.id === curPath ? 600 : 550).attr('font-family', 'sans-serif')
|
||||
.attr('font-size', 13).attr('font-weight', (d: any) => d.id === curPath ? 650 : (linkedIds.has(d.id) ? 600 : 550)).attr('font-family', 'sans-serif')
|
||||
.text((d) => truncate(d.title, 16));
|
||||
// 路径副文本(水平居中)
|
||||
nodeSel.append('text').attr('x', 0).attr('y', 13).attr('text-anchor', 'middle')
|
||||
.attr('fill', (d: any) => d.id === curPath ? 'rgba(255,255,255,0.7)' : 'var(--ink-4)')
|
||||
nodeSel.append('text').attr('x', 0).attr('y', 14).attr('text-anchor', 'middle')
|
||||
.attr('fill', (d: any) => d.id === curPath ? 'rgba(255,255,255,0.72)' : 'var(--ink-4)')
|
||||
.attr('font-size', 10).attr('font-family', 'sans-serif')
|
||||
.text((d) => truncate(d.basePath || d.id, 18));
|
||||
// 星标(右上角)
|
||||
nodeSel.filter((d) => d.star).append('text').attr('x', CARD_W / 2 - 14).attr('y', 4)
|
||||
nodeSel.filter((d) => d.star).append('text').attr('x', CARD_W / 2 - 14).attr('y', 5)
|
||||
.attr('fill', (d: any) => d.id === curPath ? 'var(--space)' : 'var(--ink-3)').attr('font-size', 14).text('★');
|
||||
// 与当前文档关联的卡片右上角加一个小圆点提示(circle,不影响 rect 计数)
|
||||
nodeSel.filter((d: any) => linkedIds.has(d.id) && d.id !== curPath).append('circle')
|
||||
.attr('cx', CARD_W / 2 - 12).attr('cy', -CARD_H / 2 + 10).attr('r', 3)
|
||||
.attr('fill', 'var(--ink-2)').attr('opacity', 0.85);
|
||||
|
||||
// 悬停高亮(CSS,不增加 rect)
|
||||
svg.append('style').text('.gcard { transition: stroke-width .16s var(--ease), stroke .16s var(--ease); } g[data-hover] .gcard { stroke: var(--line-strong); stroke-width: 1.7; }');
|
||||
|
||||
@@ -46,7 +46,9 @@ test('FR-E6:链接气泡编辑(添加+移除)', async () => {
|
||||
let html: string = await a.page.evaluate(() => document.querySelector('.ProseMirror')?.innerHTML ?? '');
|
||||
expect(html).toContain('href="https://example.com"');
|
||||
|
||||
// 气泡只在「选中链接文字」时弹出(纯点击链接改为调用系统浏览器打开)
|
||||
await a.page.locator('.ProseMirror a').click();
|
||||
await a.page.keyboard.press('Control+A');
|
||||
await expect(a.page.locator('.link-bubble')).toBeVisible({ timeout: 5000 });
|
||||
await expect(a.page.locator('.link-bubble input')).toHaveValue('https://example.com');
|
||||
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { _electron as electron } from 'playwright';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import nodePath from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
|
||||
|
||||
async function launch(userData: string) {
|
||||
const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] });
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
|
||||
return { app, page, invoke };
|
||||
}
|
||||
|
||||
// 生成长文档:足够滚动
|
||||
function longBody(title: string): string {
|
||||
const paras: string[] = [];
|
||||
for (let i = 1; i <= 800; i++) paras.push('段落 ' + i + ' 内容内容内容内容内容内容内容内容内容内容内容内容内容内容内容');
|
||||
return '# ' + title + '\n\n' + paras.join('\n\n');
|
||||
}
|
||||
|
||||
async function scrollerTop(page: any): Promise<number> {
|
||||
return page.evaluate(() => {
|
||||
const sc = document.querySelector('.ProseMirror')?.parentElement as HTMLElement | null;
|
||||
return sc ? sc.scrollTop : -1;
|
||||
});
|
||||
}
|
||||
|
||||
async function setScroll(page: any, top: number): Promise<void> {
|
||||
await page.evaluate((t: number) => {
|
||||
const sc = document.querySelector('.ProseMirror')?.parentElement as HTMLElement | null;
|
||||
if (sc) { sc.scrollTop = t; sc.dispatchEvent(new Event('scroll')); }
|
||||
}, top);
|
||||
}
|
||||
|
||||
test('FR-T5:切换文档恢复各自滚动位置;新文档从顶部', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-scroll-'));
|
||||
const userData = nodePath.join(tmp, 'ud'); const vault = nodePath.join(tmp, 'v');
|
||||
await fs.mkdir(userData, { recursive: true }); await fs.mkdir(vault, { recursive: true });
|
||||
|
||||
const a = await launch(userData);
|
||||
await a.invoke('vault:switch', { root: vault });
|
||||
await a.invoke('doc:write', { path: 'long.md', meta: {}, body: longBody('长文') });
|
||||
await a.invoke('doc:write', { path: 'short.md', meta: {}, body: '# 短文\n\n很短的内容。' });
|
||||
|
||||
// 打开长文档,滚到中部,等 rAF 记录
|
||||
await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'long.md');
|
||||
await a.page.waitForTimeout(1200);
|
||||
await setScroll(a.page, 1500);
|
||||
await a.page.waitForTimeout(400);
|
||||
|
||||
// 切到新文档(短文)——应从顶部开始
|
||||
await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'short.md');
|
||||
await a.page.waitForTimeout(400);
|
||||
const shortTop = await scrollerTop(a.page);
|
||||
expect(shortTop).toBeLessThanOrEqual(1);
|
||||
|
||||
// 切回长文档——应恢复到上次滚动位置
|
||||
await a.page.evaluate((p: string) => (globalThis as any).__STORE__?.getState?.().openDoc(p), 'long.md');
|
||||
await a.page.waitForTimeout(400);
|
||||
const restored = await scrollerTop(a.page);
|
||||
expect(restored).toBeGreaterThan(1000);
|
||||
|
||||
await a.app.close();
|
||||
});
|
||||
@@ -43,6 +43,7 @@ export const IPC = {
|
||||
appearanceSave: 'appearance:save',
|
||||
keymapLoad: 'keymap:load',
|
||||
keymapSave: 'keymap:save',
|
||||
openExternal: 'open:external',
|
||||
} as const;
|
||||
export type IpcChannel = (typeof IPC)[keyof typeof IPC];
|
||||
|
||||
@@ -86,6 +87,7 @@ export interface IpcMap {
|
||||
[IPC.appearanceSave]: { args: { themeId: string; params: Record<string, unknown>; effect: string; bgImage: string | null }; result: void };
|
||||
[IPC.keymapLoad]: { args: void; result: { id: string; combo: string }[] | null };
|
||||
[IPC.keymapSave]: { args: { id: string; combo: string }[]; result: void };
|
||||
[IPC.openExternal]: { args: { url: string }; result: void };
|
||||
}
|
||||
|
||||
// 类型安全的 invoke 帮助器签名(preload 暴露 + 渲染层使用)
|
||||
|
||||