diff --git a/src/component/EditableContent/index.js b/src/component/EditableContent/index.js
index 2d5d189..7659b71 100644
--- a/src/component/EditableContent/index.js
+++ b/src/component/EditableContent/index.js
@@ -1,5 +1,5 @@
import React, { useCallback, useImperativeHandle, useState } from "react";
-import { Button, Input, Menu, Select } from "antd";
+import { Button, Input, Menu } from "antd";
import PopoverOnSvg from "../PopoverOnSvg";
import Styles from "./index.module.css";
@@ -84,7 +84,7 @@ const EditableContent = function (
))}
);
- }, [inputValue, options]);
+ }, [initialValue, options, handleConfirm]);
let renderContent, renderPopover;
switch (inputType) {
case "select":
diff --git a/src/component/PopoverOnSvg/index.js b/src/component/PopoverOnSvg/index.js
index 1a9b0fd..6f69571 100644
--- a/src/component/PopoverOnSvg/index.js
+++ b/src/component/PopoverOnSvg/index.js
@@ -115,7 +115,7 @@ function PopoverOnSvg({
document.removeEventListener("mouseup", handler);
document.removeEventListener("keydown", handler);
};
- }, [innerVisible, autoHide, changeVisibility]);
+ }, [innerVisible, autoHide, changeVisibility, trigger]);
if (visible !== undefined && visible !== innerVisible) {
if (initFlagRef.current) {
diff --git a/src/store/global.js b/src/store/global.js
index be26392..dddf829 100644
--- a/src/store/global.js
+++ b/src/store/global.js
@@ -4,6 +4,8 @@ import { observable } from "mobx";
let globalStore = observable({
canvasWidth: 1024,
canvasHeight: 1448,
+ defaultFontSize: 16,
+ defaultSubFontSize: 12,
title: "简谱",
tone: "♭D",
marginHorizontal: 64,
@@ -11,79 +13,108 @@ let globalStore = observable({
gapAfterTitle: 16,
gapAfterHeader: 32,
gapBetweenParagraph: 32,
+ gapBetweenNotation: 8,
beat: [4, 4],
speed: 75,
authors: ["Haven Mattuy 制谱", "杨瑞光 作词"],
paragraphs: [
{
+ alignJustify: null,
notations: [
{
note: "6",
octave: -2,
- dash: 1,
+ underline: 1,
dotted: true,
},
{
note: "6",
octave: -1,
- dash: 0,
+ underline: 0,
dotted: false,
},
{
note: "3",
octave: 0,
- dash: 0,
+ underline: 0,
dotted: false,
},
{
note: "3",
octave: 0,
- dash: 0,
+ underline: 0,
dotted: false,
+ key: "hk",
+ tieTo: "ml",
},
{
note: "│",
octave: 0,
- dash: 0,
+ underline: 0,
dotted: false,
},
{
+ key: "ml",
+ tieTo: "hk",
note: "1",
octave: 0,
- dash: 0,
+ underline: 0,
dotted: false,
},
{
- note: "1",
- octave: 0,
- dash: 1,
+ note: "2",
+ octave: -1,
+ underline: 2,
+ dotted: false,
+ },
+ {
+ note: "2",
+ octave: 2,
+ underline: 1,
+ breakUnderline: true,
dotted: false,
},
{
note: "2",
octave: 0,
- dash: 1,
- dotted: false,
+ underline: 0,
+ dotted: true,
},
{
- note: "2",
+ note: "─",
octave: 0,
- dash: 0,
- dotted: false,
- },
- {
- note: "-",
- octave: 0,
- dash: 0,
+ underline: 0,
dotted: false,
},
{
note: "│",
octave: 0,
- dash: 0,
+ underline: 0,
dotted: false,
},
- ],
+ ].map((obj) => ((obj.key = obj.key || Math.random()), obj)),
+ },
+ {
+ notations: [
+ {
+ note: "0",
+ octave: 2,
+ dotted: false,
+ },
+ {
+ note: "4",
+ octave: 1,
+ dotted: false,
+ prefixSups: ["♯"],
+ topDecorators: ["※"],
+ },
+ {
+ note: "2",
+ octave: 0,
+ dotted: false,
+ },
+ ].map((obj) => ((obj.key = obj.key || Math.random()), obj)),
+ alignJustify: false,
},
{
notations: [
@@ -95,17 +126,23 @@ let globalStore = observable({
{
note: "2",
octave: 0,
- dotted: false,
+ dotted: true,
},
{
note: "4",
octave: 1,
dotted: true,
- sharpFlat: 1,
+ prefixSups: ["♯"],
+ topDecorators: ["※"],
},
- ],
+ {
+ note: "2",
+ octave: 0,
+ dotted: false,
+ },
+ ].map((obj) => ((obj.key = Math.random()), obj)),
},
- ],
+ ].map((obj) => ((obj.key = Math.random()), obj)),
// TODO: do this
popoverRefs: {},
});
diff --git a/src/util/note.js b/src/util/note.js
new file mode 100644
index 0000000..f4f4724
--- /dev/null
+++ b/src/util/note.js
@@ -0,0 +1,12 @@
+const Notes = {
+ do: "1",
+ re: "2",
+ mi: "3",
+ fa: "4",
+ sol: "5",
+ la: "6",
+ si: "7",
+ extend: "─",
+};
+
+export { Notes };
diff --git a/src/util/placement.js b/src/util/placement.js
index 1eebec7..245551b 100644
--- a/src/util/placement.js
+++ b/src/util/placement.js
@@ -1,7 +1,10 @@
import { observable } from "mobx";
import store from "../store/global";
-function calcFontData(fontSize) {
+let canvas, context2D, defaultFontFamily;
+let measureSvgEl, measureTextEl;
+
+function _calcFontData(fontSize) {
const span = document.createElement("span");
span.style.fontFamily = window.getComputedStyle(document.body).fontFamily;
span.style.fontSize = `${fontSize}px`;
@@ -17,30 +20,85 @@ function calcFontData(fontSize) {
};
}
+function _initializeCanvas(fontSize) {
+ if (!canvas) {
+ canvas = document.createElement("canvas");
+ context2D = canvas.getContext("2d");
+ defaultFontFamily = window.getComputedStyle(document.body).fontFamily;
+ }
+ context2D.font = `${
+ fontSize || store.defaultFontSize
+ }px ${defaultFontFamily}`;
+}
+
+function _calcTextWidth(fontSize, text) {
+ _initializeCanvas(fontSize);
+ return context2D.measureText(text).width;
+}
+
+function _calcTextBox(fontSize, text) {
+ if (!measureSvgEl) {
+ measureSvgEl = document.createElementNS(
+ "http://www.w3.org/2000/svg",
+ "svg"
+ );
+ measureTextEl = document.createElementNS(
+ "http://www.w3.org/2000/svg",
+ "text"
+ );
+ measureTextEl.style.visibility = "hidden";
+ measureSvgEl.appendChild(measureTextEl);
+ document.body.appendChild(measureSvgEl);
+ }
+ measureTextEl.style.fontSize = fontSize + "px";
+ measureTextEl.textContent = text;
+ return measureTextEl.getBBox();
+}
+
const placement = observable({
- get commonLineHeight() {
- return this.xHeight;
+ get maxContentWidth() {
+ return Math.max(store.canvasWidth - store.marginHorizontal * 2, 0);
+ },
+ get underlineOffsetY() {
+ return 3;
+ },
+ get octaveStepOffsetY() {
+ return 5;
+ },
+ get octaveInitialOffsetAbove() {
+ return -(placement.xHeight / 2 + 2);
+ },
+ get octaveInitialOffsetBelow() {
+ return placement.xHeight / 2 - 2;
},
get titleOffsetY() {
const titleHeight = 32;
return store.marginTop + titleHeight + store.gapAfterTitle;
},
get headerOffsetY() {
- const leftInfoBlockHeight = this.commonLineHeight * 2;
+ const leftInfoBlockHeight = this.xHeight * 2;
const titleOffset = this.titleOffsetY;
const leftInfoBlockOffset = titleOffset + leftInfoBlockHeight;
const rightInfoBlockOffset =
- titleOffset + this.commonLineHeight * store.authors.length;
+ titleOffset + this.xHeight * store.authors.length;
return (
Math.max(titleOffset, leftInfoBlockOffset, rightInfoBlockOffset) +
store.gapAfterHeader
);
},
get baseFontData() {
- return calcFontData(16);
+ const data = _calcTextBox(store.defaultFontSize, "x");
+ return {
+ xWidth: data.width,
+ xHeight: data.height,
+ };
},
get baseSubFontData() {
- return calcFontData(12);
+ const data = _calcTextBox(store.defaultSubFontSize, "x");
+ return {
+ xWidth: data.width,
+ xHeight: data.height,
+ };
},
get xHeight() {
return this.baseFontData.xHeight;
@@ -56,24 +114,100 @@ const placement = observable({
},
});
+const calcTextWidth = _calcTextWidth.bind(null, store.defaultFontSize);
+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;
}
-function calcNotationWidth(notation) {
- const noteWidth = 24;
- const dotWidth = 8;
- return noteWidth + (notation.dotted ? dotWidth : 0);
+
+function calcParagraphWidth(paragraph) {
+ const notations = paragraph.notations || [];
+ if (notations.length === 0) {
+ return 0;
+ }
+ return (
+ notations
+ .map((n) => calcNotationWidth(n))
+ .reduce((prev, curr) => prev + curr, 0) - store.gapBetweenNotation
+ );
}
+
+// 计算音符中心位置左边的宽度
+function calcNotationPrefixOffset(notation) {
+ const prefixes = notation.prefixSups || [];
+ return calcSubTextWidth(prefixes.join("")) + prefixes.length * 2;
+}
+
+// 计算音符中心位置以上的高度
+function calcNotationAboveOffset(notation) {
+ const offsets = [0];
+ const noteOffsetTop = placement.xHeight / 2;
+ offsets.push(noteOffsetTop);
+ let octaveOffset = 0;
+ if (notation.octave > 0) {
+ octaveOffset = Math.abs(
+ placement.octaveInitialOffsetAbove -
+ placement.octaveStepOffsetY * notation.octave
+ );
+ }
+ offsets.push(octaveOffset);
+ const topDecoratorOffset =
+ octaveOffset +
+ placement.subXHeight * (notation.topDecorators?.length | 0) -
+ 2;
+ offsets.push(topDecoratorOffset);
+ return Math.max(...offsets);
+}
+
+// 计算音符总体宽度
+function calcNotationWidth(notation) {
+ const noteWidth = calcTextWidth(notation.note) + store.gapBetweenNotation;
+ const dotWidth = 8;
+ const prefixWidth = calcNotationPrefixOffset(notation);
+ return prefixWidth + noteWidth + (notation.dotted ? dotWidth : 0);
+}
+
+// 计算音符自身高度,包含其装饰符,八度点等,不包括连音符
function calcNotationHeight(notation) {
- const noteHeight = 20;
- const oc = Math.abs(notation.octave) | 0;
- const octaveHeight = oc ? 2 + (oc - 1) * 5 : 0;
- return noteHeight + octaveHeight;
+ // 以音符中心为基准线,分别计算正方向和负方向的最大偏移,二者之和为音符自身高度
+ // 0保证取正方向偏移最值时不会取到负值,负方向亦然
+ const offsets = [0];
+ let noteOffsetTop = placement.xHeight / 2,
+ noteOffsetBottom = -placement.xHeight / 2,
+ octaveOffsetY,
+ supOffsetY;
+ offsets.push(noteOffsetTop, noteOffsetBottom);
+ const oc = notation.octave | 0;
+ if (oc > 0) {
+ octaveOffsetY = placement.octaveInitialOffsetAbove + 5 * oc;
+ } else if (oc < 0) {
+ octaveOffsetY = placement.octaveInitialOffsetBelow + 5 * oc;
+ } else {
+ octaveOffsetY = 0;
+ }
+ offsets.push(octaveOffsetY);
+ // 前置上标的top偏移,其定位基准为字符中心,y位置为(-P.subXHeight + 4),再
+ // 加上基准以上的半个字符的高度,得到前置上标的最大纵向偏移
+ supOffsetY = notation.prefixSups?.length ? placement.subXHeight - 4 : 0;
+ offsets.push(supOffsetY);
+ const maxOffsetY = Math.max(...offsets);
+ const minOffsetY = Math.min(...offsets);
+ return maxOffsetY - minOffsetY;
}
export default placement;
-
-export { calcParagraphHeight, calcNotationWidth };
+export {
+ calcNotationWidth,
+ calcNotationPrefixOffset,
+ calcNotationAboveOffset,
+ calcParagraphWidth,
+ calcParagraphHeight,
+ calcTextWidth,
+ calcSubTextWidth,
+};
diff --git a/src/view/Editor/index.js b/src/view/Editor/index.js
index 6710049..7299369 100644
--- a/src/view/Editor/index.js
+++ b/src/view/Editor/index.js
@@ -1,15 +1,11 @@
import SubMenu from "antd/lib/menu/SubMenu";
-import { Button, Dropdown, Menu, Tabs } from "antd";
+import { Button, Dropdown, Menu } from "antd";
import {
- DownOutlined,
EditOutlined,
- FileOutlined,
FileTextOutlined,
FolderOpenOutlined,
PlusOutlined,
- ProfileOutlined,
SaveOutlined,
- SettingOutlined,
} from "@ant-design/icons";
import { observer } from "mobx-react-lite";
import store from "../../store/global";
@@ -28,7 +24,6 @@ function Editor() {
const p = store.paragraphs[i];
heightCache[i] = heightCache[i] || calcParagraphHeight(p);
height += heightCache[i];
- console.log(store.paragraphs[i], heightCache[i]);
}
return height;
}
@@ -84,7 +79,18 @@ function Editor() {
{store.paragraphs.map((p, i) => {
- return ;
+ let alignJustify = p.alignJustify;
+ if (typeof alignJustify !== "boolean") {
+ alignJustify = !(i === store.paragraphs.length - 1);
+ }
+ return (
+
+ );
})}
diff --git a/src/view/Header/LeftInfoBlock/index.js b/src/view/Header/LeftInfoBlock/index.js
index 0a916c9..dae758f 100644
--- a/src/view/Header/LeftInfoBlock/index.js
+++ b/src/view/Header/LeftInfoBlock/index.js
@@ -61,7 +61,7 @@ function LeftInfoBlock() {
>
1 =
{store.tone.startsWith("♭") && (
-
+
♭
)}
@@ -89,7 +89,7 @@ function LeftInfoBlock() {
{store.beat[1]}
-
+
{
});
function RightInfoBlock() {
+ // TODO: working
return (
- // TODO: working
- // TODO: 添加新增指引
diff --git a/src/view/Notation/index.js b/src/view/Notation/index.js
index 6d04924..4c6dd6a 100644
--- a/src/view/Notation/index.js
+++ b/src/view/Notation/index.js
@@ -1,4 +1,10 @@
-import P, { calcParagraphHeight } from "../../util/placement";
+import { observer } from "mobx-react-lite";
+import store from "../../store/global";
+import P, {
+ calcNotationPrefixOffset,
+ calcSubTextWidth,
+} from "../../util/placement";
+import { Notes } from "../../util/note";
import Row from "../Row";
import Text from "../Text";
@@ -10,38 +16,94 @@ function composeArray(octave) {
return Array(len).fill(0, 0, len);
}
-export default function Notation({ offsetX, notation }) {
+function Notation({ offsetX, notation }) {
+ const underlineOffset = P.underlineOffsetY * (notation.underline | 0);
const octaveInitialOffset =
- notation.octave > 0 ? -(P.xHeight / 2 + 2) : P.xHeight / 2 - 2;
- const octaveStepOffset = notation.octave > 0 ? -5 : 5;
+ notation.octave > 0
+ ? P.octaveInitialOffsetAbove
+ : P.octaveInitialOffsetBelow + underlineOffset;
+ const octaveStepOffset = (notation.octave > 0 ? -1 : 1) * P.octaveStepOffsetY;
+ let topDecoratorOffset = 0;
+ if (notation.octave > 0) {
+ // 如果有高八度圆点,将顶部装饰符渲染到其上方
+ topDecoratorOffset =
+ octaveInitialOffset - octaveStepOffset * Math.abs(notation.octave);
+ }
+
+ const renderPrefixSups = () => {
+ const sups = notation.prefixSups || [];
+ const rendered = sups.map((s, i) => (
+
+ {s}
+
+ ));
+ return rendered;
+ };
+
+ const renderTopDecorators = () => {
+ const decs = notation.topDecorators || [];
+ const rendered = decs.map((d, i) => (
+
+ {d}
+
+ ));
+ return rendered;
+ };
+
+ const renderNote = () => {
+ let transform;
+ if (notation.note === Notes.extend) {
+ // 延音符太长了缩短一点
+ transform = "scale(0.8, 1)";
+ }
+ return (
+
+ {notation.note}
+
+ );
+ };
+
+ const renderOctave = () => {
+ return composeArray(notation.octave).map((oc, i) => (
+
+ ));
+ };
+
return (
- {notation.sharpFlat && (
-
- {notation.sharpFlat > 0 ? "#" : "♭"}
-
+ {renderPrefixSups()}
+ {renderTopDecorators()}
+ {renderNote()}
+ {notation.dotted && (
+
)}
-
-
- {notation.note}
-
- {notation.dotted && (
-
- )}
- {composeArray(notation.octave).map((oc, i) => (
-
- ))}
-
+ {renderOctave()}
);
}
+
+Notation.defaultProps = {
+ offsetX: 0,
+ offsetY: 0,
+};
+
+export default observer(Notation);
diff --git a/src/view/Paragraph/index.js b/src/view/Paragraph/index.js
index 051822d..4a99869 100644
--- a/src/view/Paragraph/index.js
+++ b/src/view/Paragraph/index.js
@@ -1,24 +1,159 @@
-import P, { calcNotationWidth } from "../../util/placement";
+import { useMemo } from "react";
+import P, {
+ calcNotationAboveOffset,
+ calcNotationPrefixOffset,
+ calcNotationWidth,
+ calcParagraphHeight,
+ calcParagraphWidth,
+} from "../../util/placement";
import Notation from "../Notation";
import Row from "../Row";
-function Paragraph({ paragraph, ...props }) {
+function Paragraph({ paragraph, offsetY, alignJustify }) {
+ console.log("render paragraph");
+ const notations = paragraph.notations || [];
const widthCache = [];
- function accumulate(index) {
+ const paraOffsetY = Math.max(
+ ...paragraph.notations.map((n) => calcNotationAboveOffset(n))
+ );
+ let itemFlexOffset = 0;
+ if (alignJustify && paragraph.notations?.length > 1) {
+ const realWidth = calcParagraphWidth(paragraph);
+ itemFlexOffset =
+ (P.maxContentWidth - realWidth) / (paragraph.notations.length - 1);
+ }
+
+ // 计算前n个音符的宽度
+ const accumulate = (index) => {
let width = 0;
for (let i = 0; i < index; i++) {
- const n = paragraph.notations[i];
+ const n = notations[i];
widthCache[i] = widthCache[i] || calcNotationWidth(n);
width += widthCache[i];
}
- return width;
- }
+ return width + calcNotationPrefixOffset(notations[index]);
+ };
+
+ const noteOffsets = notations.map((_, i) => {
+ return (
+ // 渲染音符时坐标为其中心,故偏移半个音符宽
+ P.xWidth / 2 +
+ // 偏移前面音符的宽度
+ accumulate(i) +
+ // 两端对齐时自动偏移
+ itemFlexOffset * i
+ );
+ });
+
+ // 绘制音符的增减时线
+ const renderUnderLine = (offsetY, fromIndex, toIndex) => {
+ return (
+
+ );
+ };
+ const renderUnderlines = () => {
+ const lines = [];
+ // 记录音符当前需要绘制的增减时线的条数
+ let helpMap = notations.map((n) => (n.underline > 0 ? n.underline : 0));
+ let baseOffsetY = P.xHeight / 2 - P.underlineOffsetY;
+ while (helpMap.some((n) => n > 0)) {
+ let fromIndex = -1;
+ let toIndex = fromIndex;
+ for (let i = 0; i < notations.length; ++i) {
+ const n = notations[i];
+ if (helpMap[i] > 0) {
+ // 当前音符需要添加增减时线
+ if (fromIndex >= 0) {
+ // 其前面的音符也需要添加增减时线
+ if (n.breakUnderline) {
+ // 当前音符禁止和前面的增减时线连续,绘制前面的增减时线
+ lines.push(renderUnderLine(baseOffsetY, fromIndex, toIndex));
+ // 记录当前音符位置等待绘制
+ fromIndex = toIndex = i;
+ } else {
+ // 当前音符可能和下一个音符的增减时线连续,本次循环不绘制
+ toIndex = i;
+ }
+ if (i === notations.length - 1) {
+ // 已是最后一个音符,直接绘制增减时线
+ lines.push(renderUnderLine(baseOffsetY, fromIndex, toIndex));
+ fromIndex = toIndex = -1;
+ }
+ } else {
+ fromIndex = toIndex = i;
+ }
+ --helpMap[i];
+ } else {
+ // 当前音符不需要绘制增减时线
+ if (fromIndex >= 0) {
+ lines.push(renderUnderLine(baseOffsetY, fromIndex, toIndex));
+ fromIndex = toIndex = -1;
+ }
+ }
+ }
+ baseOffsetY += P.underlineOffsetY;
+ }
+ return lines;
+ };
+
+ // 渲染连音线
+ const renderTie = (offsetY, fromIndex, toIndex) => {
+ const bezierX1 = 0; //noteOffsets[fromIndex];
+ const bezierX2 = bezierX1 + 4;
+ const bezierX4 = 32// noteOffsets[toIndex];
+ const bezierX3 = bezierX4 - 4;
+ const bezierY = offsetY - 12;
+ console.log(bezierX4 - bezierX1, bezierY - offsetY);
+ return (
+
+ );
+ };
+ const renderTies = () => {
+ const ties = [];
+ const offsetMap = new Map();
+ for (let i = 0; i < notations.length; ++i) {
+ const n = notations[i];
+ if (n.tieTo) {
+ const toIndex = notations.findIndex((nn) => nn.key === n.tieTo);
+ if (toIndex <= i) {
+ // tieTo属性应该是双向的,仅遍历到第一个节点时渲染
+ continue;
+ }
+ let minOffsetY = 0;
+ for (let j = i; j <= toIndex; ++j) {
+ let offset;
+ if (offsetMap.has(notations[j].key)) {
+ offset = offsetMap.get(notations[j].key);
+ } else {
+ offset = -calcNotationAboveOffset(notations[j]);
+ offsetMap.set(notations[j].key, offset);
+ }
+ minOffsetY = Math.min(minOffsetY, offset);
+ }
+ ties.push(renderTie(minOffsetY, i, toIndex));
+ }
+ }
+ return ties;
+ };
return (
-
+
+ {renderTies()}
{paragraph.notations.map((n, i) => (
-
+
))}
+ {renderUnderlines()}
);
}