daily: 2021-09-27

This commit is contained in:
2021-09-27 23:17:38 +08:00
parent fb671cd62a
commit 6634024333
18 changed files with 600 additions and 224 deletions
+17 -11
View File
@@ -1,18 +1,22 @@
import React, { useCallback, useState } from "react";
import React, { useCallback, useImperativeHandle, useState } from "react";
import { Button, Input, Menu, Select } from "antd";
import PopoverOnSvg from "../PopoverOnSvg";
import Styles from "./index.module.css";
function EditableContent({
children,
title,
initialValue,
inputType,
options,
onChange,
}) {
const EditableContent = function (
{ children, title, initialValue, inputType, options, onChange, popoverProps },
ref
) {
const [inputValue, setInputValue] = useState(initialValue);
const [popoverVisible, setPopoverVisible] = useState(false);
useImperativeHandle(
ref,
() => ({
showPopover: setPopoverVisible.bind(null, true),
hidePopover: setPopoverVisible.bind(null, false),
}),
[]
);
const handleVisibilityChange = useCallback(
function (value) {
setPopoverVisible(value);
@@ -68,6 +72,7 @@ function EditableContent({
<Menu className={Styles.menu}>
{(options || []).map((option) => (
<Menu.Item
icon={option.icon}
key={option.key}
className={
Styles.menuItem + (option.key === initialValue ? " selected" : "")
@@ -100,10 +105,11 @@ function EditableContent({
visible={popoverVisible}
placement="bottom"
onVisibilityChange={handleVisibilityChange}
{...popoverProps}
>
{children}
</PopoverOnSvg>
);
}
};
export default EditableContent;
export default React.forwardRef(EditableContent);
+32
View File
@@ -0,0 +1,32 @@
let dirty = false;
let memoizedRect = { x: 0, y: 0 };
let container;
function getPopoverContainer() {
if (!container) {
container = document.createElement("div");
container.id = "svg-popover-container";
container.style = `
position: absolute;
left: 0;
top: 0;
`;
document.body.appendChild(container);
document.addEventListener("scroll", () => void (dirty = true));
}
return container;
}
function getContainerOffset() {
if (dirty) {
const rect = getPopoverContainer().getBoundingClientRect();
memoizedRect = {
x: -rect.x,
y: -rect.y,
};
dirty = false;
}
return memoizedRect;
}
export { getPopoverContainer, getContainerOffset };
+79 -80
View File
@@ -1,6 +1,7 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Styles from "./index.module.css";
import { getContainerOffset, getPopoverContainer } from "./container";
// ENHANCE: smart placement
function PopoverOnSvg({
@@ -20,7 +21,6 @@ function PopoverOnSvg({
showArrow,
onVisibilityChange,
}) {
const containerRef = useRef();
const triggerBoxRef = useRef({
x: 0,
y: 0,
@@ -38,6 +38,7 @@ function PopoverOnSvg({
// true, we'll update it after dom mounted, because we cannot calculate
// correct position of popover before react mounting it.
const [innerVisible, setInnerVisible] = useState(false);
const [lastContextCord, setLastContextCord] = useState({ x: 0, y: 0 });
const [, forceUpdate] = useState();
const popoverClassNames = [Styles.popover, globalClassName].filter(Boolean);
const arrowClassNames = [Styles.arrow];
@@ -74,8 +75,7 @@ function PopoverOnSvg({
);
const handleContextMenu = useCallback(
(ev) => {
// TODO: working
console.log(ev.pageX, ev.pageY)
setLastContextCord({ x: ev.pageX, y: ev.pageY });
ev.preventDefault();
changeVisibility(true);
},
@@ -128,84 +128,82 @@ function PopoverOnSvg({
}, 0);
}
}
if (!containerRef.current) {
containerRef.current = document.body.querySelector(
"body > div#svg-popover-container"
);
if (!containerRef.current) {
containerRef.current = document.createElement("div");
containerRef.current.id = "svg-popover-container";
document.body.appendChild(containerRef.current);
if (trigger !== "context") {
const box = triggerBoxRef.current;
switch (true) {
case /^(left|right)(top|center|bottom)?$/i.test(placement):
mergedStyles.minHeight = "32px";
if (placement.startsWith("right")) {
mergedStyles.left = box.right + "px";
} else {
mergedStyles.left = box.left + "px";
mergedStyles.transform += " translate(-100%, 0)";
}
if (placement.endsWith("top")) {
mergedStyles.top = box.top + "px";
mergedStyles.transform += " translate(0, 0)";
arrowStyles.top = "16px";
} else if (placement.endsWith("bottom")) {
mergedStyles.top = box.bottom + "px";
mergedStyles.transform += " translate(0, -100%)";
arrowStyles.bottom = "0";
} else {
mergedStyles.top = box.y + box.height / 2 + "px";
mergedStyles.transform += " translate(0, -50%)";
arrowStyles.top = "50%";
}
break;
case /^(top|bottom)(left|center|right)?$/i.test(placement):
default:
if (placement.startsWith("bottom")) {
mergedStyles.top = box.bottom + "px";
} else {
mergedStyles.top = box.y + "px";
mergedStyles.transform += " translate(0, -100%)";
}
if (placement.endsWith("left")) {
mergedStyles.left = box.left + "px";
arrowStyles.left = "16px";
} else if (placement.endsWith("right")) {
mergedStyles.left = box.right + "px";
mergedStyles.transform += " translate(-100%, 0)";
arrowStyles.right = "0";
} else {
mergedStyles.left = box.right - box.width / 2 + "px";
mergedStyles.transform += " translateX(-50%)";
arrowStyles.left = "50%";
}
break;
}
switch (true) {
case !showArrow:
break;
case placement.startsWith("left"):
makeOffset(-8);
arrowClassNames.push("left");
break;
case placement.startsWith("right"):
makeOffset(8);
arrowClassNames.push("right");
break;
case placement.startsWith("bottom"):
makeOffset(0, 8);
arrowClassNames.push("bottom");
break;
case placement.startsWith("top"):
default:
makeOffset(0, -8);
arrowClassNames.push("top");
break;
}
makeOffset(getContainerOffset().x, getContainerOffset().y);
mergedStyles.transform += ` translate(${offsetX}px, ${offsetY}px)`;
} else {
//TODO:
mergedStyles.left = lastContextCord.x;
mergedStyles.top = lastContextCord.y;
mergedStyles.transform = `translate(${offsetX}px, ${offsetY}px)`;
}
const box = triggerBoxRef.current;
switch (true) {
case /^(left|right)(top|center|bottom)?$/i.test(placement):
mergedStyles.minHeight = "32px";
if (placement.startsWith("right")) {
mergedStyles.left = box.right + "px";
} else {
mergedStyles.left = box.left + "px";
mergedStyles.transform += " translate(-100%, 0)";
}
if (placement.endsWith("top")) {
mergedStyles.top = box.top + "px";
mergedStyles.transform += " translate(0, 0)";
arrowStyles.top = "16px";
} else if (placement.endsWith("bottom")) {
mergedStyles.top = box.bottom + "px";
mergedStyles.transform += " translate(0, -100%)";
arrowStyles.bottom = "0";
} else {
mergedStyles.top = box.y + box.height / 2 + "px";
mergedStyles.transform += " translate(0, -50%)";
arrowStyles.top = "50%";
}
break;
case /^(top|bottom)(left|center|right)?$/i.test(placement):
default:
if (placement.startsWith("bottom")) {
mergedStyles.top = box.bottom + "px";
} else {
mergedStyles.top = box.y + "px";
mergedStyles.transform += " translate(0, -100%)";
}
if (placement.endsWith("left")) {
mergedStyles.left = box.left + "px";
arrowStyles.left = "16px";
} else if (placement.endsWith("right")) {
mergedStyles.left = box.right + "px";
mergedStyles.transform += " translate(-100%, 0)";
arrowStyles.right = "0";
} else {
mergedStyles.left = box.right - box.width / 2 + "px";
mergedStyles.transform += " translateX(-50%)";
arrowStyles.left = "50%";
}
break;
}
switch (true) {
case !showArrow:
break;
case placement.startsWith("left"):
makeOffset(-8);
arrowClassNames.push("left");
break;
case placement.startsWith("right"):
makeOffset(8);
arrowClassNames.push("right");
break;
case placement.startsWith("bottom"):
makeOffset(0, 8);
arrowClassNames.push("bottom");
break;
case placement.startsWith("top"):
default:
makeOffset(0, -8);
arrowClassNames.push("top");
break;
}
mergedStyles.transform += ` translate(${offsetX}px, ${offsetY}px)`;
if (darkMode) {
popoverClassNames.push("dark");
arrowClassNames.push("dark");
@@ -261,7 +259,7 @@ function PopoverOnSvg({
</>
)}
</div>,
containerRef.current
getPopoverContainer()
)}
</>
);
@@ -270,6 +268,7 @@ function PopoverOnSvg({
PopoverOnSvg.defaultProps = {
trigger: "click",
placement: "top",
showArrow: true,
};
export default React.memo(PopoverOnSvg);
+99 -2
View File
@@ -6,11 +6,108 @@ let globalStore = observable({
canvasHeight: 1448,
title: "简谱",
tone: "♭D",
marginHorizontal: 32,
marginHorizontal: 64,
marginTop: 32,
gapAfterTitle: 16,
gapAfterHeader: 32,
gapBetweenParagraph: 32,
beat: [4, 4],
speed: 75,
authors: ["Haven Mattuy 制谱", "杨瑞光 作词"],
data: {},
paragraphs: [
{
notations: [
{
note: "6",
octave: -2,
dash: 1,
dotted: true,
},
{
note: "6",
octave: -1,
dash: 0,
dotted: false,
},
{
note: "3",
octave: 0,
dash: 0,
dotted: false,
},
{
note: "3",
octave: 0,
dash: 0,
dotted: false,
},
{
note: "│",
octave: 0,
dash: 0,
dotted: false,
},
{
note: "1",
octave: 0,
dash: 0,
dotted: false,
},
{
note: "1",
octave: 0,
dash: 1,
dotted: false,
},
{
note: "2",
octave: 0,
dash: 1,
dotted: false,
},
{
note: "2",
octave: 0,
dash: 0,
dotted: false,
},
{
note: "-",
octave: 0,
dash: 0,
dotted: false,
},
{
note: "│",
octave: 0,
dash: 0,
dotted: false,
},
],
},
{
notations: [
{
note: "1",
octave: 2,
dotted: false,
},
{
note: "2",
octave: 0,
dotted: false,
},
{
note: "4",
octave: 1,
dotted: true,
sharpFlat: 1,
},
],
},
],
// TODO: do this
popoverRefs: {},
});
export default globalStore;
+79
View File
@@ -0,0 +1,79 @@
import { observable } from "mobx";
import store from "../store/global";
function calcFontData(fontSize) {
const span = document.createElement("span");
span.style.fontFamily = window.getComputedStyle(document.body).fontFamily;
span.style.fontSize = `${fontSize}px`;
span.style.visibility = "hidden";
span.style.position = "absolute";
span.style.display = "inline-block";
span.textContent = "x";
document.body.appendChild(span);
const data = span.getBoundingClientRect();
return {
xWidth: data.width,
xHeight: data.height,
};
}
const placement = observable({
get commonLineHeight() {
return this.xHeight;
},
get titleOffsetY() {
const titleHeight = 32;
return store.marginTop + titleHeight + store.gapAfterTitle;
},
get headerOffsetY() {
const leftInfoBlockHeight = this.commonLineHeight * 2;
const titleOffset = this.titleOffsetY;
const leftInfoBlockOffset = titleOffset + leftInfoBlockHeight;
const rightInfoBlockOffset =
titleOffset + this.commonLineHeight * store.authors.length;
return (
Math.max(titleOffset, leftInfoBlockOffset, rightInfoBlockOffset) +
store.gapAfterHeader
);
},
get baseFontData() {
return calcFontData(16);
},
get baseSubFontData() {
return calcFontData(12);
},
get xHeight() {
return this.baseFontData.xHeight;
},
get xWidth() {
return this.baseFontData.xWidth;
},
get subXHeight() {
return this.baseSubFontData.xHeight;
},
get subXWidth() {
return this.baseSubFontData.xWidth;
},
});
function calcParagraphHeight(paragraph) {
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 calcNotationHeight(notation) {
const noteHeight = 20;
const oc = Math.abs(notation.octave) | 0;
const octaveHeight = oc ? 2 + (oc - 1) * 5 : 0;
return noteHeight + octaveHeight;
}
export default placement;
export { calcParagraphHeight, calcNotationWidth };
+3 -1
View File
@@ -6,10 +6,12 @@ function Canvas({ children, ...props }) {
return (
<svg
xmlns=" http://www.w3.org/2000/svg"
{...props}
className={Styles.svg}
width={store.canvasWidth}
height={store.canvasHeight}
// TODO: do this
onContextMenu={(ev) => ev.preventDefault()}
{...props}
>
{children}
</svg>
+1
View File
@@ -1,6 +1,7 @@
.svg {
border: 1px solid ghostwhite;
box-shadow: ghostwhite 0px 0px 10px;
font-size: 16px;
}
.svg :global(text) {
user-select: none;
+84 -2
View File
@@ -1,13 +1,95 @@
import SubMenu from "antd/lib/menu/SubMenu";
import { Button, Dropdown, Menu, Tabs } 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";
import P, { calcParagraphHeight } from "../../util/placement";
import Canvas from "../Canvas";
import Header from "../Header";
import Paragraph from "../Paragraph";
import Row from "../Row";
import Styles from "./index.module.css";
export default function Editor() {
function Editor() {
const heightCache = [];
function accumulate(index) {
let height = 0;
for (let i = 0; i < index; i++) {
const p = store.paragraphs[i];
heightCache[i] = heightCache[i] || calcParagraphHeight(p);
height += heightCache[i];
console.log(store.paragraphs[i], heightCache[i]);
}
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}>
<div
className={Styles.container}
style={{ width: store.canvasWidth + "px" }}
>
<div className={Styles.headerWrapper}>
<div
className={Styles.header}
style={{ width: store.canvasWidth + "px" }}
>
<Dropdown overlay={fileMenu} placement="bottomLeft">
<Button icon={<FileTextOutlined />} type="text">
文件
</Button>
</Dropdown>
<Dropdown overlay={editMenu} placement="bottomLeft">
<Button icon={<EditOutlined />} type="text">
编辑
</Button>
</Dropdown>
</div>
</div>
<Canvas>
<Header />
<Row offsetX={store.marginHorizontal} offsetY={P.headerOffsetY}>
{store.paragraphs.map((p, i) => {
return <Paragraph key={p} paragraph={p} offsetY={accumulate(i)} />;
})}
</Row>
</Canvas>
</div>
);
}
export default observer(Editor);
+17 -6
View File
@@ -1,7 +1,18 @@
.container {
background-color: white;
height: 100%;
display: flex;
justify-content: center;
padding-top: 8px;
.headerWrapper {
margin-bottom: 16px;
border-bottom: 1px solid whitesmoke;
width: 100%;
}
.header {
margin: auto;
padding: 8px 0;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: white;
min-width: 100%;
height: 100%;
}
+12 -7
View File
@@ -2,6 +2,7 @@ import { action } from "mobx";
import { message } from "antd";
import { observer } from "mobx-react-lite";
import EditableContent from "../../../component/EditableContent";
import P from "../../../util/placement";
import store from "../../../store/global";
import Row from "../../Row";
import Text from "../../Text";
@@ -22,7 +23,6 @@ const tones = [
"♭F",
"♭G",
].map((t) => ({ key: t, text: t }));
const beats = ["4/4"].map((t) => ({ key: t, text: t }));
const handleChangeTone = action(function (value) {
store.tone = value;
});
@@ -53,15 +53,20 @@ function LeftInfoBlock() {
options={tones}
onChange={handleChangeTone}
>
<Row type="tone" editable offsetX={store.marginHorizontal} offsetY="64">
<Row
type="tone"
editable
offsetX={store.marginHorizontal}
offsetY={P.titleOffsetY}
>
<Text>1&nbsp;&nbsp;= </Text>
{store.tone.startsWith("♭") && (
<Text x="25" y="-2" fontSize="12">
<Text x="27" y="-2" fontSize="12">
</Text>
)}
<Text editable x={store.tone.startsWith("♭") ? 34 : 28}>
<Text editable x={store.tone.startsWith("♭") ? 36 : 29}>
{store.tone.at(-1)}
</Text>
</Row>
@@ -76,7 +81,7 @@ function LeftInfoBlock() {
type="beat"
editable
offsetX={store.marginHorizontal + 64}
offsetY="64"
offsetY={P.titleOffsetY}
>
<Text x="0" y="-8" textAnchor="middle">
{store.beat[0]}
@@ -97,10 +102,10 @@ function LeftInfoBlock() {
editable
type="speed"
offsetX={store.marginHorizontal}
offsetY="86"
offsetY={P.titleOffsetY + 22}
>
<Text x="-4"></Text>
<Text x="14">=&nbsp;&nbsp;{store.speed}</Text>
<Text x="16">=&nbsp;{store.speed}</Text>
</Row>
</EditableContent>
</>
+84 -21
View File
@@ -1,40 +1,103 @@
import { DeleteOutlined, EditOutlined, PlusOutlined } from "@ant-design/icons";
import { Modal } from "antd";
import { action } from "mobx";
import { observer } from "mobx-react-lite";
import EditableContent from "../../../component/EditableContent";
import PopoverOnSvg from "../../../component/PopoverOnSvg";
import escapeHtml from "../../../util/html-escape";
import store from "../../../store/global";
import P from "../../../util/placement";
import Row from "../../Row";
import Text from "../../Text";
const authorContextMenu = [
{
key: "create",
text: "添加",
icon: <PlusOutlined style={{ color: "grey" }} />,
onClick: () => {
store.authors.push("作者信息");
},
},
{
key: "delete",
text: "删除",
icon: <DeleteOutlined style={{ color: "grey" }} />,
onClick: (index) => {
store.authors.splice(index, 1);
},
},
];
const blockContextMenu = [
{
key: "create",
text: "添加作者信息",
icon: <PlusOutlined style={{ color: "grey" }} />,
onClick: () => {
store.authors.push("【作曲者】 作曲");
store.authors.push("【记谱者】 记谱");
},
},
];
const handleSelectMenu = action((index, value) => {
const menu = authorContextMenu.find((m) => m.key === value);
menu?.onClick(index);
});
const handleSelectBlockMenu = action((value) => {
const menu = blockContextMenu.find((m) => m.key === value);
menu?.onClick();
});
const handleChangeAuthor = action((index, value) => {
if (!value) {
return new Promise((resolve) => {
Modal.confirm({
title: "删除这条作者信息?",
okText: "确定",
cancelText: "取消",
onOk: () => {
resolve(true);
store.authors.splice(index, 1);
},
onCancel: () => {
resolve(true);
},
});
});
}
store.authors[index] = value;
});
function RightInfoBlock() {
return (
// TODO: working
<PopoverOnSvg
trigger="context"
placement="leftCenter"
renderPopover="x"
offset={{ x: -32 }}
<Row
type="authors"
offsetX={store.canvasWidth - store.marginHorizontal}
offsetY={P.titleOffsetY}
>
<Row
type="authors"
offsetX={store.canvasWidth - store.marginHorizontal}
offsetY="64"
<EditableContent
inputType="select"
options={blockContextMenu}
popoverProps={{ trigger: "context" }}
onChange={handleSelectBlockMenu}
>
// TODO: 添加新增指引
<rect
x="-100"
y="-4"
width="100"
height={store.authors.length * 22}
fill="transparent"
x={store.marginHorizontal - 150}
y="-64"
width={150}
height={150}
fill={store.authors.length ? "none" : "transparent"}
// className={Styles.hotCorner}
></rect>
{store.authors.map((author, i) => (
</EditableContent>
{store.authors.map((author, i) => (
<EditableContent
key={i + author}
inputType="select"
options={authorContextMenu}
popoverProps={{ trigger: "context" }}
onChange={handleSelectMenu.bind(null, i)}
>
<EditableContent
key={author}
title="作者信息:"
initialValue={author}
onChange={handleChangeAuthor.bind(null, i)}
@@ -43,9 +106,9 @@ function RightInfoBlock() {
{author}
</Text>
</EditableContent>
))}
</Row>
</PopoverOnSvg>
</EditableContent>
))}
</Row>
);
}
+10 -1
View File
@@ -1,5 +1,6 @@
import { action } from "mobx";
import { observer } from "mobx-react-lite";
import { useEffect, useRef } from "react";
import EditableContent from "../../../component/EditableContent";
import store from "../../../store/global";
import Row from "../../Row";
@@ -9,9 +10,17 @@ const handleChangeTitle = action((value) => {
});
function Title() {
const ref = useRef();
useEffect(() => {
store.popoverRefs.title = ref;
return () => {
store.popoverRefs.title = null;
};
}, []);
return (
<Row type="title" offsetY="32">
<Row type="title" offsetY={store.marginTop}>
<EditableContent
ref={ref}
title="歌曲名称:"
initialValue={store.title}
onChange={handleChangeTitle}
+40 -3
View File
@@ -1,10 +1,47 @@
import P, { calcParagraphHeight } from "../../util/placement";
import Row from "../Row";
import Text from "../Text";
export default function Notation(...props) {
function composeArray(octave) {
const len = Math.abs(Number(octave));
if (!Number.isInteger(len)) {
return [];
}
return Array(len).fill(0, 0, len);
}
export default function Notation({ offsetX, notation }) {
const octaveInitialOffset =
notation.octave > 0 ? -(P.xHeight / 2 + 2) : P.xHeight / 2 - 2;
const octaveStepOffset = notation.octave > 0 ? -5 : 5;
return (
<Row type="notation" {...props}>
<Text y="100">1</Text>
<Row type="notation" offsetX={offsetX}>
{notation.sharpFlat && (
<Text
dominantBaseline="middle"
textAnchor="middle"
fontSize="12"
y={-P.subXHeight / 2 + 4}
>
{notation.sharpFlat > 0 ? "#" : "♭"}
</Text>
)}
<Row offsetX={notation.sharpFlat ? P.subXWidth + 2 : 0}>
<Text dominantBaseline="middle" textAnchor="middle">
{notation.note}
</Text>
{notation.dotted && (
<circle type="dot" cx={P.xWidth + 4} cy="0" r="2"></circle>
)}
{composeArray(notation.octave).map((oc, i) => (
<circle
type="octave"
cx="0"
cy={octaveStepOffset * i + octaveInitialOffset}
r="2"
></circle>
))}
</Row>
</Row>
);
}
+26
View File
@@ -0,0 +1,26 @@
import P, { calcNotationWidth } from "../../util/placement";
import Notation from "../Notation";
import Row from "../Row";
function Paragraph({ paragraph, ...props }) {
const widthCache = [];
function accumulate(index) {
let width = 0;
for (let i = 0; i < index; i++) {
const n = paragraph.notations[i];
widthCache[i] = widthCache[i] || calcNotationWidth(n);
width += widthCache[i];
}
return width;
}
return (
<Row type="paragraph" {...props}>
{paragraph.notations.map((n, i) => (
<Notation key={n} notation={n} offsetX={accumulate(i)} />
))}
</Row>
);
}
export default Paragraph;