first commit
This commit is contained in:
5
js/src/admin/index.ts
Normal file
5
js/src/admin/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import app from 'flarum/admin/app';
|
||||
|
||||
app.initializers.add('klxf/flarum-coyote-pulse-viewer', () => {
|
||||
console.log('[klxf/flarum-coyote-pulse-viewer] Hello, admin!');
|
||||
});
|
||||
5
js/src/common/index.ts
Normal file
5
js/src/common/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import app from 'flarum/common/app';
|
||||
|
||||
app.initializers.add('klxf/flarum-coyote-pulse-viewer', () => {
|
||||
console.log('[klxf/flarum-coyote-pulse-viewer] Hello, forum and admin!');
|
||||
});
|
||||
56
js/src/forum/index.ts
Normal file
56
js/src/forum/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import app from 'flarum/forum/app';
|
||||
import {extend} from "flarum/common/extend";
|
||||
import TextEditor from "flarum/common/components/TextEditor";
|
||||
import TextEditorButton from "flarum/common/components/TextEditorButton";
|
||||
import renderPulseViewers from "./util/renderPulseViewers";
|
||||
|
||||
app.initializers.add('klxf-coyote-pulse-viewer', () => {
|
||||
extend(TextEditor.prototype, 'toolbarItems', function (items) {
|
||||
items.add(
|
||||
'pulse-file-upload',
|
||||
m(TextEditorButton, {
|
||||
icon: 'fas fa-file-import',
|
||||
title: '波形分享',
|
||||
onclick: () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.pulse';
|
||||
input.style.display = 'none';
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
const file = input.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const fileNameWithoutExtension = file.name.replace(/\.pulse$/, '');
|
||||
const match = fileNameWithoutExtension.match(/^pulse-(.+?)-\d+$/);
|
||||
|
||||
let title: string;
|
||||
|
||||
if (match) title = match[1];
|
||||
else title = fileNameWithoutExtension;
|
||||
|
||||
const pulseText = `[pulse title="${title}"]${reader.result}[/pulse]`;
|
||||
// @ts-ignore
|
||||
this.attrs?.composer?.editor.insertAtCursor(pulseText);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}
|
||||
});
|
||||
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
document.body.removeChild(input);
|
||||
}
|
||||
}),
|
||||
81
|
||||
);
|
||||
});
|
||||
|
||||
renderPulseViewers();
|
||||
document.body.addEventListener('contentupdated', renderPulseViewers);
|
||||
const observer = new MutationObserver(() => {
|
||||
renderPulseViewers();
|
||||
});
|
||||
observer.observe(document.body, {childList: true, subtree: true});
|
||||
});
|
||||
151
js/src/forum/util/generateSVG.ts
Normal file
151
js/src/forum/util/generateSVG.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
const FREQ_SLIDER_VALUE_MAP = [
|
||||
10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36,
|
||||
37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49,
|
||||
50, 52, 54, 56, 58, 60, 62, 64, 66, 68, 70, 72, 74, 76, 78,
|
||||
80, 85, 90, 95,
|
||||
100, 110, 120, 130, 140, 150, 160, 170, 180, 190,
|
||||
200, 233, 266, 300, 333, 366,
|
||||
400, 450, 500, 550,
|
||||
600, 700, 800, 900, 1000
|
||||
];
|
||||
|
||||
const SECTION_TIME_MAP = [
|
||||
0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9, 2, 2.1, 2.2,
|
||||
2.3, 2.4, 2.5, 2.6, 2.7, 2.8, 2.9, 3, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6, 3.7, 3.8, 3.9, 4, 4.1, 4.2, 4.3, 4.4,
|
||||
4.5, 4.6, 4.7, 4.8, 4.9,
|
||||
5, 5.2, 5.4, 5.6, 5.8, 6, 6.2, 6.4, 6.6, 6.8, 7, 7.2, 7.4, 7.6, 7.8,
|
||||
8, 8.5, 9, 9.5,
|
||||
10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
|
||||
20, 23.4, 26.6, 30, 33.4, 36.6,
|
||||
40, 45, 50, 55,
|
||||
60, 70, 80, 90,
|
||||
100, 120, 140, 160, 180,
|
||||
200, 250, 300
|
||||
];
|
||||
|
||||
function generateSVG(data: string) {
|
||||
const sections = data.replace('Dungeonlab+pulse:', '').split('+section+');
|
||||
|
||||
let svgWidth = 0;
|
||||
let svgHeight = 100;
|
||||
|
||||
let svgContent = '';
|
||||
|
||||
const startPadding = 0;
|
||||
const endPadding = 0;
|
||||
svgWidth += startPadding;
|
||||
|
||||
let restDuration = 0;
|
||||
let speedRate = 1;
|
||||
|
||||
// 只在最开始解析新的数据格式
|
||||
const firstSectionParts = sections[0].split('=');
|
||||
if (firstSectionParts.length === 2) {
|
||||
const [prefix, data] = firstSectionParts;
|
||||
const [rest, speed, num] = prefix.split(',').map(Number);
|
||||
restDuration = rest || 0;
|
||||
speedRate = speed || 1;
|
||||
sections[0] = data; // 将 sections[0] 替换为原始数据部分
|
||||
}
|
||||
|
||||
sections.forEach(section => {
|
||||
const [header, pulsesStr] = section.split('/');
|
||||
const [minFreq, maxFreq, durationIndex, mode, isOn] = header.split(',').map(Number);
|
||||
const duration = SECTION_TIME_MAP[durationIndex];
|
||||
|
||||
if (isOn === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pulses = pulsesStr.split(',');
|
||||
const pulsesDuration = pulses.length * 10; // 计算pulses总宽度, 每个 pulse 宽度为 10
|
||||
const repeatTimes = Math.ceil(duration / (pulsesDuration /
|
||||
100)); // pulsesDuration 单位是 0.1 秒,duration 单位是秒,需要转换单位, pulsesDuration / 100 得到秒
|
||||
|
||||
for (let j = 0; j < repeatTimes; j++) {
|
||||
const sectionWidth = pulses.length * 10;
|
||||
svgWidth += sectionWidth;
|
||||
|
||||
pulses.forEach((pulseStr, index) => {
|
||||
const [value, type] = pulseStr.split('-').map(Number);
|
||||
const pulseHeight = value;
|
||||
|
||||
const xPos = svgWidth - sectionWidth + index * 10;
|
||||
const yPos = svgHeight - pulseHeight;
|
||||
|
||||
const pulseWidth = 10; // 每个 pulse 宽度固定为 10
|
||||
let freq;
|
||||
|
||||
if (mode === 1) {
|
||||
// 频率恒定
|
||||
freq = FREQ_SLIDER_VALUE_MAP[minFreq];
|
||||
} else if (mode === 2) {
|
||||
// 频率从 minFreq 增加到 maxFreq
|
||||
freq = FREQ_SLIDER_VALUE_MAP[minFreq + Math.floor((maxFreq - minFreq) * ((pulses
|
||||
.length * j + index) / (pulses.length * repeatTimes)))];
|
||||
} else if (mode === 3) {
|
||||
// 每个脉冲元内频率从 minFreq 增加到 maxFreq
|
||||
freq = FREQ_SLIDER_VALUE_MAP[minFreq + Math.floor((maxFreq - minFreq) * index /
|
||||
pulses.length)];
|
||||
} else if (mode === 4) {
|
||||
// 频率变化仅发生在脉冲元之间
|
||||
freq = FREQ_SLIDER_VALUE_MAP[minFreq + Math.floor((maxFreq - minFreq) * (j /
|
||||
repeatTimes))];
|
||||
} else {
|
||||
freq = FREQ_SLIDER_VALUE_MAP[minFreq];
|
||||
}
|
||||
|
||||
const blackBarWidth = 0.5;
|
||||
const pulseDurationInSecond = pulseWidth / 100; // 假设 10 宽度对应 0.1 秒,需要根据实际情况调整
|
||||
const numBars = Math.floor(pulseDurationInSecond * 1000 / freq); // 频率单位是毫秒,pulseDurationInSecond 单位是秒,需要转换单位
|
||||
|
||||
if (numBars > 0) {
|
||||
let whiteBarWidth = (pulseWidth - numBars * blackBarWidth) / numBars;
|
||||
if (whiteBarWidth < 0) whiteBarWidth = 0; // 避免条纹宽度为负数的情况
|
||||
|
||||
for (let i = 0; i < numBars; i++) {
|
||||
const barXPos = xPos + i * (blackBarWidth + whiteBarWidth);
|
||||
svgContent +=
|
||||
`<rect x="${barXPos}" y="${yPos}" width="${blackBarWidth}" height="${pulseHeight}" fill="#ffe99d" />`;
|
||||
const whiteBarXPos = barXPos + blackBarWidth;
|
||||
svgContent +=
|
||||
`<rect x="${whiteBarXPos}" y="${yPos}" width="${whiteBarWidth}" height="${pulseHeight}" fill="#1a1a1a" />`;
|
||||
}
|
||||
} else {
|
||||
// 如果 numBars 为 0, 则填充整个 pulse
|
||||
svgContent +=
|
||||
`<rect x="${xPos}" y="${yPos}" width="${pulseWidth}" height="${pulseHeight}" fill="#1a1a1a" />`;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
svgWidth += endPadding;
|
||||
|
||||
const scrollSpeed = 50 * speedRate; // 应用 speedRate
|
||||
const totalScrollDistance = svgWidth + 100;
|
||||
const animationDuration = totalScrollDistance / scrollSpeed;
|
||||
|
||||
const animationContent = `
|
||||
<animateTransform
|
||||
attributeName="transform"
|
||||
attributeType="XML"
|
||||
type="translate"
|
||||
from="${-startPadding + 200}"
|
||||
to="${-svgWidth - 100}"
|
||||
dur="${animationDuration}s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
`;
|
||||
|
||||
return `
|
||||
<svg width="300" height="100" viewBox="${-startPadding} 0 100 ${svgHeight}">
|
||||
<g>
|
||||
${svgContent}
|
||||
${animationContent}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
export default generateSVG;
|
||||
51
js/src/forum/util/renderPulseViewers.ts
Normal file
51
js/src/forum/util/renderPulseViewers.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import generateSVG from './generateSVG';
|
||||
import validatePulseData from "./validatePulseData";
|
||||
|
||||
function renderPulseViewers() {
|
||||
document.querySelectorAll('.coyote-pulse-viewer').forEach(el => {
|
||||
if (el.getAttribute('data-pulse-rendered')) return;
|
||||
let error = false;
|
||||
let errorArray: string[] = [];
|
||||
const pulseData = el.getAttribute('data-pulse');
|
||||
const pulseTitle = el.getAttribute('data-title') ? el.getAttribute('data-title'): '自定义波形';
|
||||
const pulseVersion = el.getAttribute('data-version') ? el.getAttribute('data-version') : '3';
|
||||
if (pulseData) {
|
||||
el.innerHTML += `<div class="pulse-title-box"><span class="pulse-title">波形: ${pulseTitle && pulseTitle.length > 12 ? pulseTitle.slice(0, 12) + '...' : pulseTitle}</span><span class="pulse-version">v${pulseVersion}</span><button class="Button Button--primary hasIcon pulse-download-btn"><i class="fas fa-file-download"></i></button></div>`;
|
||||
|
||||
if (pulseVersion != '3') {
|
||||
error = true;
|
||||
errorArray.push('目前仅支持 v3 版本的波形数据');
|
||||
}
|
||||
|
||||
if (!validatePulseData(pulseData)) {
|
||||
error = true;
|
||||
errorArray.push('解析失败,请检查数据格式是否正确');
|
||||
}
|
||||
|
||||
if (!error) {
|
||||
el.innerHTML += generateSVG(pulseData);
|
||||
} else {
|
||||
el.querySelector('.pulse-download-btn')?.remove();
|
||||
el.innerHTML += `<div class="pulse-warning"><i class="fas fa-exclamation-circle"></i> 错误:${errorArray.join('; ')}</div>`;
|
||||
}
|
||||
|
||||
el.setAttribute('data-pulse-rendered', '1');
|
||||
|
||||
const downloadBtn = el.querySelector('.pulse-download-btn');
|
||||
if (downloadBtn) {
|
||||
downloadBtn.addEventListener('click', () => {
|
||||
const blob = new Blob([pulseData], { type: 'text/plain' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${pulseTitle}.pulse`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(a.href);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default renderPulseViewers;
|
||||
81
js/src/forum/util/validatePulseData.ts
Normal file
81
js/src/forum/util/validatePulseData.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
function validatePulseData(decodedData: string) {
|
||||
// 1. 检查开头是否为 "Dungeonlab+pulse:"
|
||||
if (!decodedData.startsWith("Dungeonlab+pulse:")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 移除开头标识
|
||||
const data = decodedData.substring("Dungeonlab+pulse:".length);
|
||||
|
||||
// 3. 分割数据段
|
||||
const sections = data.split("+section+");
|
||||
|
||||
// 4. 校验第一段数据(兼容新格式)
|
||||
const firstSection = sections[0];
|
||||
const firstSectionParts = firstSection.split("/");
|
||||
|
||||
// 检查是否存在前缀
|
||||
const prefixRegex = /^(\d+,\d+,\d+=)?/; // 匹配 "数字,数字,数字=" 的可选前缀
|
||||
const prefixMatch = firstSection.match(prefixRegex);
|
||||
const hasPrefix = prefixMatch && prefixMatch[1];
|
||||
|
||||
let intCounts, floatCounts;
|
||||
|
||||
if (hasPrefix) {
|
||||
// 新格式:包含前缀
|
||||
const dataPart = firstSection.substring(prefixMatch[0].length); // 移除前缀
|
||||
const dataParts = dataPart.split("/");
|
||||
|
||||
if (dataParts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
intCounts = dataParts[0].split(",");
|
||||
floatCounts = dataParts[1].split(",");
|
||||
} else {
|
||||
// 旧格式:不包含前缀
|
||||
if (firstSectionParts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
intCounts = firstSectionParts[0].split(",");
|
||||
floatCounts = firstSectionParts[1].split(",");
|
||||
}
|
||||
|
||||
if (intCounts.length !== 5) {
|
||||
return false;
|
||||
}
|
||||
if (!intCounts.every(count => /^\d+$/.test(count))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!floatCounts.every(count => /^-?\d+(\.\d+)?-\d+$/.test(count))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. 校验后续数据段(与旧格式相同)
|
||||
for (let i = 1; i < sections.length; i++) {
|
||||
const section = sections[i];
|
||||
const sectionParts = section.split("/");
|
||||
if (sectionParts.length !== 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const intCounts = sectionParts[0].split(",");
|
||||
if (intCounts.length !== 5) {
|
||||
return false;
|
||||
}
|
||||
if (!intCounts.every(count => /^\d+$/.test(count))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const floatCounts = sectionParts[1].split(",");
|
||||
if (!floatCounts.every(count => /^-?\d+(\.\d+)?-\d+$/.test(count))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export default validatePulseData;
|
||||
Reference in New Issue
Block a user