diff --git a/src/store/global.js b/src/store/global.js index dddf829..b4ae971 100644 --- a/src/store/global.js +++ b/src/store/global.js @@ -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 }; diff --git a/src/store/history.js b/src/store/history.js new file mode 100644 index 0000000..461bc21 --- /dev/null +++ b/src/store/history.js @@ -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 }; diff --git a/src/util/placement.js b/src/util/placement.js index 245551b..6d32f13 100644 --- a/src/util/placement.js +++ b/src/util/placement.js @@ -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, }; diff --git a/src/view/Canvas/index.js b/src/view/Canvas/index.js index ab24b3a..b379d12 100644 --- a/src/view/Canvas/index.js +++ b/src/view/Canvas/index.js @@ -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 ( - }> - 新建 - - }> - 打开 - - }> - 保存 - - - ); - const editMenu = ( - - 编辑曲名 - - Option 1 - Option 2 - Option 3 - Option 4 - - - ); - return (
+ + + +
@@ -94,6 +85,10 @@ function Editor() { })} + ); } diff --git a/src/view/Header/LeftInfoBlock/index.js b/src/view/Header/LeftInfoBlock/index.js index dae758f..041a178 100644 --- a/src/view/Header/LeftInfoBlock/index.js +++ b/src/view/Header/LeftInfoBlock/index.js @@ -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("请以【*/*】的格式输入节拍!"); diff --git a/src/view/Header/RightInfoBlock/index.js b/src/view/Header/RightInfoBlock/index.js index 03ec564..7869987 100644 --- a/src/view/Header/RightInfoBlock/index.js +++ b/src/view/Header/RightInfoBlock/index.js @@ -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: , 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({ diff --git a/src/view/Header/Title/index.js b/src/view/Header/Title/index.js index b22b8a3..dc5bd11 100644 --- a/src/view/Header/Title/index.js +++ b/src/view/Header/Title/index.js @@ -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 ( 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" > )); diff --git a/src/view/Paragraph/index.js b/src/view/Paragraph/index.js index 4a99869..281fd44 100644 --- a/src/view/Paragraph/index.js +++ b/src/view/Paragraph/index.js @@ -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 ( + {renderTies()} {paragraph.notations.map((n, i) => ( diff --git a/src/view/menu/editor.js b/src/view/menu/editor.js new file mode 100644 index 0000000..3f6d632 --- /dev/null +++ b/src/view/menu/editor.js @@ -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 = ( + + }> + 新建 + + }> + 打开 + + }> + 保存 + + +); +const editMenu = ( + + 撤销 + 重做 + 重置歌曲名称 + 重置作者信息 + +); + +const convertMenu = ( + + + 1 = C + 1 = D + 1 = E + 1 = F + 1 = G + 1 = A + 1 = B + + 1 = C + + + 1 = D + {" "} + + 1 = E + {" "} + + 1 = F + {" "} + + 1 = G + {" "} + + 1 = A + + + 1 = B + + + 升高一个音 + 降低一个音 + 升高一个八度 + 降低一个八度 + +); + +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 };