daily: 2021-09-29

This commit is contained in:
2021-09-29 15:41:58 +08:00
parent 47f9643aed
commit 488a2532b2
12 changed files with 313 additions and 92 deletions
+16 -15
View File
@@ -12,7 +12,7 @@ let globalStore = observable({
marginTop: 32,
gapAfterTitle: 16,
gapAfterHeader: 32,
gapBetweenParagraph: 32,
gapBetweenParagraph: 0,
gapBetweenNotation: 8,
beat: [4, 4],
speed: 75,
@@ -24,13 +24,13 @@ let globalStore = observable({
{
note: "6",
octave: -2,
underline: 1,
underline: 2,
dotted: true,
},
{
note: "6",
octave: -1,
underline: 0,
underline: 1,
dotted: false,
},
{
@@ -45,7 +45,7 @@ let globalStore = observable({
underline: 0,
dotted: false,
key: "hk",
tieTo: "ml",
// tieTo: "ml",
},
{
note: "│",
@@ -55,7 +55,7 @@ let globalStore = observable({
},
{
key: "ml",
tieTo: "hk",
// tieTo: "hk",
note: "1",
octave: 0,
underline: 0,
@@ -63,14 +63,14 @@ let globalStore = observable({
},
{
note: "2",
octave: -1,
underline: 2,
// octave: -1,
// underline: 2,
dotted: false,
},
{
note: "2",
octave: 2,
underline: 1,
// underline: 1,
breakUnderline: true,
dotted: false,
},
@@ -98,15 +98,15 @@ let globalStore = observable({
notations: [
{
note: "0",
octave: 2,
octave: 0,
dotted: false,
},
{
note: "4",
octave: 1,
octave: 0,
dotted: false,
prefixSups: ["♯"],
topDecorators: ["※"],
// prefixSups: ["♯"],
// topDecorators: ["※", "※", "※"],
},
{
note: "2",
@@ -143,9 +143,10 @@ let globalStore = observable({
].map((obj) => ((obj.key = Math.random()), obj)),
},
].map((obj) => ((obj.key = Math.random()), obj)),
// TODO: do this
popoverRefs: {},
});
const GlobalContext = React.createContext(globalStore);
export default globalStore;
export const GlobalContext = React.createContext(globalStore);
export { GlobalContext };
+54
View File
@@ -0,0 +1,54 @@
import { action, intercept, runInAction, toJS } from "mobx";
import globalStore from "./global";
const storeHistory = [];
let cursor = -1;
intercept(globalStore, (change) => {
storeHistory.push(toJS(globalStore));
return change;
});
// 在历史记录中漫游
function go(span) {
const newCursor = Math.min(
Math.max(0, cursor + span),
storeHistory.length - 1
);
if (newCursor === cursor) {
return;
}
cursor = newCursor;
Object.assign(globalStore, storeHistory[newCursor]);
}
// 包装mobx的action以实现历史记录
function executeAction(actionFunc, ...args) {
storeHistory[++cursor] = toJS(globalStore);
storeHistory.length = cursor + 1;
if (storeHistory.length > 256) {
storeHistory.splice(0, 256 - storeHistory.length);
}
actionFunc.apply(this, args);
}
function wrappedAction(...args) {
let name, func;
if (typeof args[0] === "function") {
func = args[0];
} else {
name = args[0];
func = args[1];
}
const realArgs = [name, executeAction.bind(this, func)];
if (name === undefined) {
realArgs.shift();
}
return action.apply(this, realArgs);
}
function runInWrappedAction(func) {
runInAction(() => {
executeAction.call(this, func);
});
}
export { go, wrappedAction, runInWrappedAction };
+76 -13
View File
@@ -1,6 +1,8 @@
import { observable } from "mobx";
import store from "../store/global";
// ENHANCE: 通过mobx的computed机制缓存一些计算值
let canvas, context2D, defaultFontFamily;
let measureSvgEl, measureTextEl;
@@ -55,18 +57,35 @@ function _calcTextBox(fontSize, text) {
return measureTextEl.getBBox();
}
// 判断段落中的音符是否有连音线
function _hasTie(paragraph) {
const notations = paragraph?.notations || [];
return notations.some(
(n, i) => n.tieTo && notations.findIndex((nn) => nn.key === n.tieTo) > i
);
}
const placement = observable({
get maxContentWidth() {
return Math.max(store.canvasWidth - store.marginHorizontal * 2, 0);
},
get underlineOffsetY() {
get minTieHeight() {
return 8;
},
get maxTieHeight() {
return 25;
},
get underlineStepOffsetY() {
return 3;
},
get underlineInitialOffsetY() {
return this.xHeight / 2 - 3;
},
get octaveStepOffsetY() {
return 5;
},
get octaveInitialOffsetAbove() {
return -(placement.xHeight / 2 + 2);
return placement.xHeight / 2 + 2;
},
get octaveInitialOffsetBelow() {
return placement.xHeight / 2 - 2;
@@ -119,13 +138,22 @@ const calcSubTextWidth = _calcTextWidth.bind(null, store.defaultSubFontSize);
// 计算段落行高
function calcParagraphHeight(paragraph) {
// TODO: 连音符高度
const maxNoteHeight = Math.max(
...paragraph.notations.map((n) => calcNotationHeight(n))
);
return maxNoteHeight + store.gapBetweenParagraph;
const notations = paragraph.notations || [];
let tieHeight = 0;
if (_hasTie(paragraph)) {
tieHeight = placement.maxTieHeight;
}
// 段落高度不是最高的音符的高度,而是上边偏移最大的音符的上部分偏移量+下边偏移最
// 大的音符的下部分偏移量
const noteHeightMap = notations.map((n) => calcNotationHeight(n));
const aboveOffsetMap = notations.map((n) => calcNotationAboveOffset(n));
const belowOffsetMap = noteHeightMap.map((v, i) => v - aboveOffsetMap[i]);
const maxNoteOffset =
Math.max(...belowOffsetMap) + Math.max(...aboveOffsetMap);
return tieHeight + maxNoteOffset + store.gapBetweenParagraph;
}
// 计算一行中所有音符的宽度和
function calcParagraphWidth(paragraph) {
const notations = paragraph.notations || [];
if (notations.length === 0) {
@@ -138,6 +166,19 @@ function calcParagraphWidth(paragraph) {
);
}
// 计算段落中的音符中心以上偏移量的最大值
function calcParagraphAboveOffset(paragraph) {
const notations = paragraph.notations || [];
let tieHeight = 0;
if (_hasTie(paragraph)) {
tieHeight = placement.maxTieHeight;
}
const notationOffset = notations
.map((n) => calcNotationAboveOffset(n))
.reduce((prev, curr) => Math.max(prev, curr), placement.xHeight / 2);
return notationOffset + tieHeight;
}
// 计算音符中心位置左边的宽度
function calcNotationPrefixOffset(notation) {
const prefixes = notation.prefixSups || [];
@@ -151,10 +192,9 @@ function calcNotationAboveOffset(notation) {
offsets.push(noteOffsetTop);
let octaveOffset = 0;
if (notation.octave > 0) {
octaveOffset = Math.abs(
placement.octaveInitialOffsetAbove -
placement.octaveStepOffsetY * notation.octave
);
octaveOffset =
placement.octaveInitialOffsetAbove +
placement.octaveStepOffsetY * Math.abs(notation.octave);
}
offsets.push(octaveOffset);
const topDecoratorOffset =
@@ -184,10 +224,20 @@ function calcNotationHeight(notation) {
supOffsetY;
offsets.push(noteOffsetTop, noteOffsetBottom);
const oc = notation.octave | 0;
const underlineOffsetY =
notation.underline > 0
? placement.underlineInitialOffsetY +
placement.underlineStepOffsetY * (notation.underline | 0)
: 0;
offsets.push(underlineOffsetY);
const octaveInitialOffsetY =
notation.octave > 0
? -placement.octaveInitialOffsetAbove
: placement.octaveInitialOffsetBelow + underlineOffsetY;
if (oc > 0) {
octaveOffsetY = placement.octaveInitialOffsetAbove + 5 * oc;
octaveOffsetY = octaveInitialOffsetY - 5 * Math.abs(oc);
} else if (oc < 0) {
octaveOffsetY = placement.octaveInitialOffsetBelow + 5 * oc;
octaveOffsetY = octaveInitialOffsetY + 5 * Math.abs(oc);
} else {
octaveOffsetY = 0;
}
@@ -196,6 +246,18 @@ function calcNotationHeight(notation) {
// 加上基准以上的半个字符的高度,得到前置上标的最大纵向偏移
supOffsetY = notation.prefixSups?.length ? placement.subXHeight - 4 : 0;
offsets.push(supOffsetY);
// 顶部装饰符在高八度点之上
let octaveOffsetYAbove = 0;
if (notation.octave > 0) {
octaveOffsetYAbove =
placement.octaveInitialOffsetAbove -
placement.octaveStepOffsetY * notation.octave;
}
const topDecoratorOffset =
octaveOffsetYAbove -
placement.subXHeight * (notation.topDecorators?.length | 0) -
2;
offsets.push(topDecoratorOffset);
const maxOffsetY = Math.max(...offsets);
const minOffsetY = Math.min(...offsets);
return maxOffsetY - minOffsetY;
@@ -208,6 +270,7 @@ export {
calcNotationAboveOffset,
calcParagraphWidth,
calcParagraphHeight,
calcParagraphAboveOffset,
calcTextWidth,
calcSubTextWidth,
};
+2
View File
@@ -2,6 +2,8 @@ import { observer } from "mobx-react-lite";
import store from "../../store/global";
import Styles from "./index.module.css";
// ENHANCE: 增加整体缩放能力
function Canvas({ children, ...props }) {
return (
<svg
+6
View File
@@ -0,0 +1,6 @@
function ConfigModal({ visible, onVisibleChange }) {
return null;
}
// TODO: do this
export default ConfigModal;
+27 -32
View File
@@ -1,58 +1,41 @@
import SubMenu from "antd/lib/menu/SubMenu";
import { Button, Dropdown, Menu } from "antd";
import { Button, Dropdown } from "antd";
import {
EditOutlined,
FileTextOutlined,
FolderOpenOutlined,
PlusOutlined,
SaveOutlined,
SettingOutlined,
SyncOutlined,
} from "@ant-design/icons";
import { observer } from "mobx-react-lite";
import { useState } from "react";
import store from "../../store/global";
import P, { calcParagraphHeight } from "../../util/placement";
import P, {
calcParagraphAboveOffset,
calcParagraphHeight,
} from "../../util/placement";
import Canvas from "../Canvas";
import ConfigModal from "../ConfigModal";
import Header from "../Header";
import Paragraph from "../Paragraph";
import Row from "../Row";
import { convertMenu, editMenu, fileMenu } from "../menu/editor";
import Styles from "./index.module.css";
function Editor() {
const [isModalVisible, setIsModalVisible] = useState(false);
const heightCache = [];
function accumulate(index) {
const paragraphs = store.paragraphs || [];
let height = 0;
for (let i = 0; i < index; i++) {
const p = store.paragraphs[i];
const p = paragraphs[i];
heightCache[i] = heightCache[i] || calcParagraphHeight(p);
height += heightCache[i];
}
// 段落渲染定位基于段落中心,因此加上上半部分的偏移量
height += calcParagraphAboveOffset(paragraphs[index]);
return height;
}
const fileMenu = (
<Menu>
<Menu.Item key="create" icon={<PlusOutlined />}>
新建
</Menu.Item>
<Menu.Item key="open" icon={<FolderOpenOutlined />}>
打开
</Menu.Item>
<Menu.Item key="save" icon={<SaveOutlined />}>
保存
</Menu.Item>
</Menu>
);
const editMenu = (
<Menu>
<Menu.Item key="title">编辑曲名</Menu.Item>
<SubMenu key="tone" title="转调">
<Menu.Item key="setting:1">Option 1</Menu.Item>
<Menu.Item key="setting:2">Option 2</Menu.Item>
<Menu.Item key="setting:3">Option 3</Menu.Item>
<Menu.Item key="setting:4">Option 4</Menu.Item>
</SubMenu>
</Menu>
);
return (
<div
className={Styles.container}
@@ -73,6 +56,14 @@ function Editor() {
编辑
</Button>
</Dropdown>
<Dropdown overlay={convertMenu} placement="bottomLeft">
<Button icon={<SyncOutlined />} type="text">
转调
</Button>
</Dropdown>
<Button icon={<SettingOutlined />} type="text">
配置
</Button>
</div>
</div>
<Canvas>
@@ -94,6 +85,10 @@ function Editor() {
})}
</Row>
</Canvas>
<ConfigModal
visible={isModalVisible}
onVisibleChange={setIsModalVisible}
></ConfigModal>
</div>
);
}
+8 -7
View File
@@ -4,32 +4,33 @@ import { observer } from "mobx-react-lite";
import EditableContent from "../../../component/EditableContent";
import P from "../../../util/placement";
import store from "../../../store/global";
import { wrappedAction } from "../../../store/history";
import Row from "../../Row";
import Text from "../../Text";
const tones = [
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"A",
"B",
"A",
"B",
"♭C",
"♭D",
"♭E",
"♭F",
"♭G",
"♭A",
"♭B",
].map((t) => ({ key: t, text: t }));
const handleChangeTone = action(function (value) {
const handleChangeTone = wrappedAction(function (value) {
store.tone = value;
});
const handleChangeSpeed = action(function (value) {
const handleChangeSpeed = wrappedAction(function (value) {
store.speed = value;
});
const handleChangeBeat = action(function (value) {
const handleChangeBeat = wrappedAction(function (value) {
const beat = String(value).split("/");
if (beat.length !== 2) {
message.error("请以【*/*】的格式输入节拍!");
+4 -4
View File
@@ -5,6 +5,7 @@ import { observer } from "mobx-react-lite";
import EditableContent from "../../../component/EditableContent";
import P from "../../../util/placement";
import store from "../../../store/global";
import { wrappedAction } from "../../../store/history";
import Row from "../../Row";
import Text from "../../Text";
@@ -32,20 +33,19 @@ const blockContextMenu = [
text: "添加作者信息",
icon: <PlusOutlined style={{ color: "grey" }} />,
onClick: () => {
store.authors.push("【作曲者】 作曲");
store.authors.push("【记谱者】 记谱");
},
},
];
const handleSelectMenu = action((index, value) => {
const handleSelectMenu = wrappedAction((index, value) => {
const menu = authorContextMenu.find((m) => m.key === value);
menu?.onClick(index);
});
const handleSelectBlockMenu = action((value) => {
const handleSelectBlockMenu = wrappedAction((value) => {
const menu = blockContextMenu.find((m) => m.key === value);
menu?.onClick();
});
const handleChangeAuthor = action((index, value) => {
const handleChangeAuthor = wrappedAction((index, value) => {
if (!value) {
return new Promise((resolve) => {
Modal.confirm({
+2 -7
View File
@@ -3,20 +3,15 @@ import { observer } from "mobx-react-lite";
import { useEffect, useRef } from "react";
import EditableContent from "../../../component/EditableContent";
import store from "../../../store/global";
import { wrappedAction } from "../../../store/history";
import Row from "../../Row";
import Text from "../../Text";
const handleChangeTitle = action((value) => {
const handleChangeTitle = wrappedAction((value) => {
store.title = value;
});
function Title() {
const ref = useRef();
useEffect(() => {
store.popoverRefs.title = ref;
return () => {
store.popoverRefs.title = null;
};
}, []);
return (
<Row type="title" offsetY={store.marginTop}>
<EditableContent
+3 -3
View File
@@ -17,10 +17,10 @@ function composeArray(octave) {
}
function Notation({ offsetX, notation }) {
const underlineOffset = P.underlineOffsetY * (notation.underline | 0);
const underlineOffset = P.underlineStepOffsetY * (notation.underline | 0);
const octaveInitialOffset =
notation.octave > 0
? P.octaveInitialOffsetAbove
? -P.octaveInitialOffsetAbove
: P.octaveInitialOffsetBelow + underlineOffset;
const octaveStepOffset = (notation.octave > 0 ? -1 : 1) * P.octaveStepOffsetY;
let topDecoratorOffset = 0;
@@ -82,7 +82,7 @@ function Notation({ offsetX, notation }) {
key={i}
type="octave"
cx="0"
cy={octaveStepOffset * i + octaveInitialOffset}
cy={octaveInitialOffset + octaveStepOffset * i}
r="2"
></circle>
));
+18 -11
View File
@@ -3,6 +3,7 @@ import P, {
calcNotationAboveOffset,
calcNotationPrefixOffset,
calcNotationWidth,
calcParagraphAboveOffset,
calcParagraphHeight,
calcParagraphWidth,
} from "../../util/placement";
@@ -13,9 +14,6 @@ function Paragraph({ paragraph, offsetY, alignJustify }) {
console.log("render paragraph");
const notations = paragraph.notations || [];
const widthCache = [];
const paraOffsetY = Math.max(
...paragraph.notations.map((n) => calcNotationAboveOffset(n))
);
let itemFlexOffset = 0;
if (alignJustify && paragraph.notations?.length > 1) {
const realWidth = calcParagraphWidth(paragraph);
@@ -31,7 +29,9 @@ function Paragraph({ paragraph, offsetY, alignJustify }) {
widthCache[i] = widthCache[i] || calcNotationWidth(n);
width += widthCache[i];
}
return width + calcNotationPrefixOffset(notations[index]);
// 音符的定位基准是其中心,因此要加上当前音符的前缀部分偏移
width += calcNotationPrefixOffset(notations[index]);
return width;
};
const noteOffsets = notations.map((_, i) => {
@@ -62,7 +62,7 @@ function Paragraph({ paragraph, offsetY, alignJustify }) {
const lines = [];
// 记录音符当前需要绘制的增减时线的条数
let helpMap = notations.map((n) => (n.underline > 0 ? n.underline : 0));
let baseOffsetY = P.xHeight / 2 - P.underlineOffsetY;
let baseOffsetY = P.underlineInitialOffsetY;
while (helpMap.some((n) => n > 0)) {
let fromIndex = -1;
let toIndex = fromIndex;
@@ -98,19 +98,26 @@ function Paragraph({ paragraph, offsetY, alignJustify }) {
}
}
}
baseOffsetY += P.underlineOffsetY;
baseOffsetY += P.underlineStepOffsetY;
}
return lines;
};
// 渲染连音线
const renderTie = (offsetY, fromIndex, toIndex) => {
const bezierX1 = 0; //noteOffsets[fromIndex];
const bezierX1 = noteOffsets[fromIndex];
const bezierX2 = bezierX1 + 4;
const bezierX4 = 32// noteOffsets[toIndex];
const bezierX4 = noteOffsets[toIndex];
const bezierX3 = bezierX4 - 4;
const bezierY = offsetY - 12;
console.log(bezierX4 - bezierX1, bezierY - offsetY);
const x = bezierX4 - bezierX1;
// ENHANCE: 优化贝赛尔曲线
let y =
0.000021917145491103032 * x * x * x -
0.0062948773732605465 * x * x +
0.6378785891964498 * x -
0.6843414475200537;
y = Math.max(Math.min(y, P.maxTieHeight), P.minTieHeight);
const bezierY = offsetY - y;
return (
<path
d={`M${bezierX1} ${offsetY} C${bezierX2} ${bezierY} ${bezierX3} ${bezierY} ${bezierX4} ${offsetY}`}
@@ -148,7 +155,7 @@ function Paragraph({ paragraph, offsetY, alignJustify }) {
};
return (
<Row type="paragraph" offsetY={offsetY + paraOffsetY}>
<Row type="paragraph" offsetY={offsetY}>
{renderTies()}
{paragraph.notations.map((n, i) => (
<Notation key={n.key} notation={n} offsetX={noteOffsets[i]} />
+97
View File
@@ -0,0 +1,97 @@
import {
EditOutlined,
FileTextOutlined,
FolderOpenOutlined,
PlusOutlined,
SaveOutlined,
} from "@ant-design/icons";
import { Menu } from "antd";
import store from "../../store/global";
import { go, runInWrappedAction } from "../../store/history";
const { SubMenu } = Menu;
const fileMenu = (
<Menu>
<Menu.Item key="create" icon={<PlusOutlined />}>
新建
</Menu.Item>
<Menu.Item key="open" icon={<FolderOpenOutlined />}>
打开
</Menu.Item>
<Menu.Item key="save" icon={<SaveOutlined />}>
保存
</Menu.Item>
</Menu>
);
const editMenu = (
<Menu onClick={handleMenu}>
<Menu.Item key="undo">撤销</Menu.Item>
<Menu.Item key="redo">重做</Menu.Item>
<Menu.Item key="reset-title">重置歌曲名称</Menu.Item>
<Menu.Item key="reset-authors">重置作者信息</Menu.Item>
</Menu>
);
const convertMenu = (
<Menu key="tone" title="转调">
<SubMenu key="convert-to" title="转到...">
<Menu.Item key="convert-to-C">1 = C</Menu.Item>
<Menu.Item key="convert-to-D">1 = D</Menu.Item>
<Menu.Item key="convert-to-E">1 = E</Menu.Item>
<Menu.Item key="convert-to-F">1 = F</Menu.Item>
<Menu.Item key="convert-to-G">1 = G</Menu.Item>
<Menu.Item key="convert-to-A">1 = A</Menu.Item>
<Menu.Item key="convert-to-B">1 = B</Menu.Item>
<Menu.Item key="convert-to-bC">
1 = <sup></sup>C
</Menu.Item>
<Menu.Item key="convert-to-bD">
1 = <sup></sup>D
</Menu.Item>{" "}
<Menu.Item key="convert-to-bE">
1 = <sup></sup>E
</Menu.Item>{" "}
<Menu.Item key="convert-to-bF">
1 = <sup></sup>F
</Menu.Item>{" "}
<Menu.Item key="convert-to-bG">
1 = <sup></sup>G
</Menu.Item>{" "}
<Menu.Item key="convert-to-bA">
1 = <sup></sup>A
</Menu.Item>
<Menu.Item key="convert-to-bB">
1 = <sup></sup>B
</Menu.Item>
</SubMenu>
<Menu.Item key="convert-up">升高一个音</Menu.Item>
<Menu.Item key="convert-down">降低一个音</Menu.Item>
<Menu.Item key="convert-up8">升高一个八度</Menu.Item>
<Menu.Item key="convert-down8">降低一个八度</Menu.Item>
</Menu>
);
function handleMenu({ key }) {
switch (key) {
case "undo":
go(-1);
break;
case "redo":
go(1);
break;
case "reset-title":
runInWrappedAction(() => {
store.title = "【歌曲名称】";
});
break;
case "reset-authors":
store.authors = [
"【作曲者】 作曲",
"【填词者】 填词",
"【记谱者】 记谱",
];
break;
}
}
export { fileMenu, editMenu, convertMenu };