mirror of
https://github.com/Boof2015/prism.git
synced 2026-08-28 16:50:57 +02:00
fix icon for clip drag
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
"preview": "electron-vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build:icons": "node scripts/build/generate-prism-icons.mjs",
|
||||
"build:clip-drag-icons": "node scripts/build/generate-audio-clip-drag-icons.mjs",
|
||||
"test": "node scripts/run-tests.mjs",
|
||||
"test:audio-router": "node scripts/run-audio-router-tests.mjs",
|
||||
"test:audio-clips": "node scripts/run-audio-clip-tests.mjs",
|
||||
@@ -107,6 +108,10 @@
|
||||
"from": "resources/icon.png",
|
||||
"to": "icon.png"
|
||||
},
|
||||
{
|
||||
"from": "resources/drag/",
|
||||
"to": "drag/"
|
||||
},
|
||||
{
|
||||
"from": "resources/tray/",
|
||||
"to": "tray/"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 312 B |
Binary file not shown.
|
After Width: | Height: | Size: 504 B |
@@ -0,0 +1,236 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { deflateSync } from 'node:zlib'
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
const repoRoot = resolve(scriptDir, '../..')
|
||||
const outputDir = join(repoRoot, 'resources', 'drag')
|
||||
const logicalSize = 32
|
||||
const supersample = 4
|
||||
const pngSignature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
|
||||
const colors = {
|
||||
outline: [232, 241, 247, 255],
|
||||
page: [9, 16, 23, 255],
|
||||
fold: [26, 43, 55, 255],
|
||||
waveform: [48, 197, 255, 255],
|
||||
}
|
||||
|
||||
const documentOutline = [
|
||||
[8.25, 2],
|
||||
[18.75, 2],
|
||||
[26, 9.25],
|
||||
[26, 27],
|
||||
[25.7, 28.25],
|
||||
[24.7, 29.5],
|
||||
[8.25, 29.5],
|
||||
[7, 29.2],
|
||||
[6, 28.2],
|
||||
[6, 4.75],
|
||||
[6.3, 3.5],
|
||||
[7.05, 2.55],
|
||||
]
|
||||
|
||||
const documentPage = [
|
||||
[8.4, 3.7],
|
||||
[18.05, 3.7],
|
||||
[24.3, 9.95],
|
||||
[24.3, 27.1],
|
||||
[23.75, 27.8],
|
||||
[8.25, 27.8],
|
||||
[7.7, 27.15],
|
||||
[7.7, 4.55],
|
||||
]
|
||||
|
||||
const documentFold = [
|
||||
[19.1, 4.75],
|
||||
[23.25, 8.9],
|
||||
[19.1, 8.9],
|
||||
]
|
||||
|
||||
const waveformBars = [
|
||||
{ x: 10.5, y: 16.5, height: 5 },
|
||||
{ x: 13.25, y: 13.5, height: 11 },
|
||||
{ x: 16, y: 15, height: 8 },
|
||||
{ x: 18.75, y: 12.5, height: 13 },
|
||||
{ x: 21.5, y: 16, height: 6 },
|
||||
]
|
||||
|
||||
function makeCrc32Table() {
|
||||
const table = new Uint32Array(256)
|
||||
for (let i = 0; i < table.length; i += 1) {
|
||||
let value = i
|
||||
for (let bit = 0; bit < 8; bit += 1) {
|
||||
value = (value & 1) !== 0
|
||||
? 0xedb88320 ^ (value >>> 1)
|
||||
: value >>> 1
|
||||
}
|
||||
table[i] = value >>> 0
|
||||
}
|
||||
return table
|
||||
}
|
||||
|
||||
const crc32Table = makeCrc32Table()
|
||||
|
||||
function crc32(buffer) {
|
||||
let crc = 0xffffffff
|
||||
for (const byte of buffer) {
|
||||
crc = crc32Table[(crc ^ byte) & 0xff] ^ (crc >>> 8)
|
||||
}
|
||||
return (crc ^ 0xffffffff) >>> 0
|
||||
}
|
||||
|
||||
function pngChunk(type, data) {
|
||||
const typeBuffer = Buffer.from(type, 'ascii')
|
||||
const lengthBuffer = Buffer.alloc(4)
|
||||
lengthBuffer.writeUInt32BE(data.length, 0)
|
||||
const crcBuffer = Buffer.alloc(4)
|
||||
crcBuffer.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0)
|
||||
return Buffer.concat([lengthBuffer, typeBuffer, data, crcBuffer])
|
||||
}
|
||||
|
||||
function writePng(filePath, width, height, pixels) {
|
||||
const stride = width * 4
|
||||
const raw = Buffer.alloc((stride + 1) * height)
|
||||
|
||||
for (let y = 0; y < height; y += 1) {
|
||||
const rowOffset = y * (stride + 1)
|
||||
raw[rowOffset] = 0
|
||||
pixels.copy(raw, rowOffset + 1, y * stride, (y + 1) * stride)
|
||||
}
|
||||
|
||||
const header = Buffer.alloc(13)
|
||||
header.writeUInt32BE(width, 0)
|
||||
header.writeUInt32BE(height, 4)
|
||||
header.writeUInt8(8, 8)
|
||||
header.writeUInt8(6, 9)
|
||||
|
||||
writeFileSync(filePath, Buffer.concat([
|
||||
pngSignature,
|
||||
pngChunk('IHDR', header),
|
||||
pngChunk('IDAT', deflateSync(raw, { level: 9 })),
|
||||
pngChunk('IEND', Buffer.alloc(0)),
|
||||
]))
|
||||
}
|
||||
|
||||
function pointInPolygon(x, y, points) {
|
||||
let inside = false
|
||||
for (let i = 0, j = points.length - 1; i < points.length; j = i, i += 1) {
|
||||
const [xi, yi] = points[i]
|
||||
const [xj, yj] = points[j]
|
||||
if ((yi > y) !== (yj > y) && x < ((xj - xi) * (y - yi) / (yj - yi)) + xi) {
|
||||
inside = !inside
|
||||
}
|
||||
}
|
||||
return inside
|
||||
}
|
||||
|
||||
function setPixel(pixels, width, x, y, color) {
|
||||
const offset = ((y * width) + x) * 4
|
||||
pixels[offset] = color[0]
|
||||
pixels[offset + 1] = color[1]
|
||||
pixels[offset + 2] = color[2]
|
||||
pixels[offset + 3] = color[3]
|
||||
}
|
||||
|
||||
function fillPolygon(pixels, width, height, points, color, scale) {
|
||||
const scaledPoints = points.map(([x, y]) => [x * scale, y * scale])
|
||||
const xs = scaledPoints.map(([x]) => x)
|
||||
const ys = scaledPoints.map(([, y]) => y)
|
||||
const minX = Math.max(0, Math.floor(Math.min(...xs)))
|
||||
const maxX = Math.min(width - 1, Math.ceil(Math.max(...xs)))
|
||||
const minY = Math.max(0, Math.floor(Math.min(...ys)))
|
||||
const maxY = Math.min(height - 1, Math.ceil(Math.max(...ys)))
|
||||
|
||||
for (let y = minY; y <= maxY; y += 1) {
|
||||
for (let x = minX; x <= maxX; x += 1) {
|
||||
if (pointInPolygon(x + 0.5, y + 0.5, scaledPoints)) {
|
||||
setPixel(pixels, width, x, y, color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function fillRoundedRect(pixels, width, height, rect, color, scale) {
|
||||
const left = (rect.x - (rect.width / 2)) * scale
|
||||
const top = rect.y * scale
|
||||
const rectWidth = rect.width * scale
|
||||
const rectHeight = rect.height * scale
|
||||
const radius = Math.min(rectWidth / 2, rectHeight / 2)
|
||||
const right = left + rectWidth
|
||||
const bottom = top + rectHeight
|
||||
|
||||
for (let y = Math.max(0, Math.floor(top)); y < Math.min(height, Math.ceil(bottom)); y += 1) {
|
||||
for (let x = Math.max(0, Math.floor(left)); x < Math.min(width, Math.ceil(right)); x += 1) {
|
||||
const sampleX = x + 0.5
|
||||
const sampleY = y + 0.5
|
||||
const nearestX = Math.max(left + radius, Math.min(right - radius, sampleX))
|
||||
const nearestY = Math.max(top + radius, Math.min(bottom - radius, sampleY))
|
||||
const dx = sampleX - nearestX
|
||||
const dy = sampleY - nearestY
|
||||
if ((dx * dx) + (dy * dy) <= radius * radius) {
|
||||
setPixel(pixels, width, x, y, color)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function downsample(source, sourceSize, targetSize) {
|
||||
const ratio = sourceSize / targetSize
|
||||
const target = Buffer.alloc(targetSize * targetSize * 4)
|
||||
|
||||
for (let y = 0; y < targetSize; y += 1) {
|
||||
for (let x = 0; x < targetSize; x += 1) {
|
||||
let alpha = 0
|
||||
let red = 0
|
||||
let green = 0
|
||||
let blue = 0
|
||||
|
||||
for (let sampleY = 0; sampleY < ratio; sampleY += 1) {
|
||||
for (let sampleX = 0; sampleX < ratio; sampleX += 1) {
|
||||
const sourceOffset = ((((y * ratio) + sampleY) * sourceSize) + (x * ratio) + sampleX) * 4
|
||||
const sampleAlpha = source[sourceOffset + 3]
|
||||
alpha += sampleAlpha
|
||||
red += source[sourceOffset] * sampleAlpha
|
||||
green += source[sourceOffset + 1] * sampleAlpha
|
||||
blue += source[sourceOffset + 2] * sampleAlpha
|
||||
}
|
||||
}
|
||||
|
||||
const targetOffset = ((y * targetSize) + x) * 4
|
||||
const sampleCount = ratio * ratio
|
||||
target[targetOffset + 3] = Math.round(alpha / sampleCount)
|
||||
if (alpha > 0) {
|
||||
target[targetOffset] = Math.round(red / alpha)
|
||||
target[targetOffset + 1] = Math.round(green / alpha)
|
||||
target[targetOffset + 2] = Math.round(blue / alpha)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
function renderIcon(size) {
|
||||
const sourceSize = size * supersample
|
||||
const scale = sourceSize / logicalSize
|
||||
const source = Buffer.alloc(sourceSize * sourceSize * 4)
|
||||
|
||||
fillPolygon(source, sourceSize, sourceSize, documentOutline, colors.outline, scale)
|
||||
fillPolygon(source, sourceSize, sourceSize, documentPage, colors.page, scale)
|
||||
fillPolygon(source, sourceSize, sourceSize, documentFold, colors.fold, scale)
|
||||
for (const bar of waveformBars) {
|
||||
fillRoundedRect(source, sourceSize, sourceSize, {
|
||||
...bar,
|
||||
width: 1.7,
|
||||
}, colors.waveform, scale)
|
||||
}
|
||||
|
||||
return downsample(source, sourceSize, size)
|
||||
}
|
||||
|
||||
mkdirSync(outputDir, { recursive: true })
|
||||
for (const [filename, size] of [['audio-clip.png', 32], ['audio-clip@2x.png', 64]]) {
|
||||
writePng(join(outputDir, filename), size, size, renderIcon(size))
|
||||
}
|
||||
+25
-2
@@ -153,6 +153,8 @@ const NOW_PLAYING_CONFIG_DEFAULTS = {
|
||||
}
|
||||
|
||||
const STATIC_APP_ICON_FILENAME = 'icon.png'
|
||||
const AUDIO_CLIP_DRAG_ICON_FILENAME = 'audio-clip.png'
|
||||
const AUDIO_CLIP_DRAG_ICON_SIZE = 32
|
||||
const DESKTOP_INTEGRATION_PREFS_FILENAME = 'desktop-integration.json'
|
||||
const MAIN_WINDOW_SYNC_SUPPRESSION_MS = 180
|
||||
const MAIN_WINDOW_VISIBLE_GRAB_MARGIN = 64
|
||||
@@ -377,11 +379,32 @@ function getStaticWindowIconOptions(): Pick<BrowserWindowConstructorOptions, 'ic
|
||||
return icon ? { icon } : {}
|
||||
}
|
||||
|
||||
function getAudioClipDragIconPath(): string | undefined {
|
||||
const candidates = app.isPackaged
|
||||
? [join(process.resourcesPath, 'drag', AUDIO_CLIP_DRAG_ICON_FILENAME)]
|
||||
: [
|
||||
join(process.cwd(), 'resources', 'drag', AUDIO_CLIP_DRAG_ICON_FILENAME),
|
||||
join(__dirname, '../../resources/drag', AUDIO_CLIP_DRAG_ICON_FILENAME),
|
||||
]
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate))
|
||||
}
|
||||
|
||||
function getAudioClipDragIcon() {
|
||||
const iconPath = getStaticAppIconPath()
|
||||
const iconPath = getAudioClipDragIconPath()
|
||||
if (iconPath) {
|
||||
const icon = nativeImage.createFromPath(iconPath)
|
||||
if (!icon.isEmpty()) return icon
|
||||
if (!icon.isEmpty()) {
|
||||
const size = icon.getSize()
|
||||
if (size.width > AUDIO_CLIP_DRAG_ICON_SIZE || size.height > AUDIO_CLIP_DRAG_ICON_SIZE) {
|
||||
return icon.resize({
|
||||
width: AUDIO_CLIP_DRAG_ICON_SIZE,
|
||||
height: AUDIO_CLIP_DRAG_ICON_SIZE,
|
||||
quality: 'best',
|
||||
})
|
||||
}
|
||||
return icon
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = nativeImage.createFromDataURL(
|
||||
|
||||
@@ -4492,6 +4492,36 @@ test('rolling capture exposes persisted duration controls and a native toolbar d
|
||||
assert.match(trayBridgeSource, /rollingCaptureSeconds,/)
|
||||
})
|
||||
|
||||
test('rolling capture uses a dedicated bounded audio-file drag preview', async () => {
|
||||
const mainSource = await readFile(join(process.cwd(), 'src', 'main', 'index.ts'), 'utf8')
|
||||
const packageSource = await readFile(join(process.cwd(), 'package.json'), 'utf8')
|
||||
const icon1x = await readFile(join(process.cwd(), 'resources', 'drag', 'audio-clip.png'))
|
||||
const icon2x = await readFile(join(process.cwd(), 'resources', 'drag', 'audio-clip@2x.png'))
|
||||
const packageJson = JSON.parse(packageSource) as {
|
||||
build: {
|
||||
extraResources: Array<{ from: string; to: string }>
|
||||
}
|
||||
}
|
||||
|
||||
const pngDimensions = (buffer: Buffer): [number, number] => {
|
||||
assert.deepEqual([...buffer.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10])
|
||||
return [buffer.readUInt32BE(16), buffer.readUInt32BE(20)]
|
||||
}
|
||||
|
||||
assert.deepEqual(pngDimensions(icon1x), [32, 32])
|
||||
assert.deepEqual(pngDimensions(icon2x), [64, 64])
|
||||
assert.match(mainSource, /const AUDIO_CLIP_DRAG_ICON_FILENAME = 'audio-clip\.png'/)
|
||||
assert.match(mainSource, /const AUDIO_CLIP_DRAG_ICON_SIZE = 32/)
|
||||
assert.match(mainSource, /function getAudioClipDragIconPath\(\)/)
|
||||
assert.match(mainSource, /const iconPath = getAudioClipDragIconPath\(\)/)
|
||||
assert.match(mainSource, /icon\.resize\(\{[\s\S]*width: AUDIO_CLIP_DRAG_ICON_SIZE,[\s\S]*height: AUDIO_CLIP_DRAG_ICON_SIZE/)
|
||||
assert.doesNotMatch(mainSource, /function getAudioClipDragIcon\(\) \{\s*const iconPath = getStaticAppIconPath\(\)/)
|
||||
assert.match(mainSource, /event\.sender\.startDrag\(\{\s*file: filePath,\s*icon: getAudioClipDragIcon\(\),\s*\}\)/)
|
||||
assert.ok(packageJson.build.extraResources.some((entry) => {
|
||||
return entry.from === 'resources/drag/' && entry.to === 'drag/'
|
||||
}))
|
||||
})
|
||||
|
||||
test('resolveWindowCapabilities detects native Wayland sessions on Linux', () => {
|
||||
assert.deepEqual(
|
||||
resolveWindowCapabilities({
|
||||
|
||||
Reference in New Issue
Block a user