daily: 2021-09-26

This commit is contained in:
2021-09-27 00:40:21 +08:00
parent 93e19c05af
commit fb671cd62a
22 changed files with 2295 additions and 64 deletions
+1440
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -6,6 +6,9 @@
"@testing-library/jest-dom": "^5.14.1",
"@testing-library/react": "^11.2.7",
"@testing-library/user-event": "^12.8.3",
"antd": "^4.16.13",
"mobx": "^6.3.3",
"mobx-react-lite": "^3.2.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
"react-scripts": "4.0.3",
+7 -30
View File
@@ -1,43 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<html lang="zh_CN">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
/>
<meta name="description" content="Web site created using create-react-app" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<title>简单简谱编辑器</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<noscript>要使用本程序,请启用JavaScript。</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
</body>
</html>
+5 -19
View File
@@ -1,24 +1,10 @@
import logo from './logo.svg';
import './App.css';
import Editor from "./view/Editor";
import globalStore, { GlobalContext } from "./store/global";
function App() {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.js</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
<GlobalContext.Provider value={globalStore}>
<Editor></Editor>
</GlobalContext.Provider>
);
}
+109
View File
@@ -0,0 +1,109 @@
import React, { useCallback, 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 [inputValue, setInputValue] = useState(initialValue);
const [popoverVisible, setPopoverVisible] = useState(false);
const handleVisibilityChange = useCallback(
function (value) {
setPopoverVisible(value);
if (!value) {
setInputValue(initialValue);
}
},
[initialValue]
);
const handleInput = useCallback(function (ev) {
setInputValue(ev.target.value);
}, []);
const handleConfirm = useCallback(
async function (value) {
const v = value === undefined ? inputValue : value;
const success = await (onChange && onChange(v));
success !== false && setPopoverVisible(false);
},
[inputValue, onChange]
);
const handleCancel = useCallback(
function () {
setInputValue(initialValue);
setPopoverVisible(false);
},
[initialValue]
);
const renderTextInputPopover = useCallback(
() => (
<>
<Input
autoFocus
value={inputValue}
onChange={handleInput}
onPressEnter={() => handleConfirm()}
/>
<div className={Styles.buttonGroup}>
<Button
type="primary"
className={Styles.button}
onClick={() => handleConfirm()}
>
确定
</Button>
<Button onClick={handleCancel}>取消</Button>
</div>
</>
),
[inputValue, handleInput, handleConfirm, handleCancel]
);
const renderSelectionPopover = useCallback(() => {
return (
<Menu className={Styles.menu}>
{(options || []).map((option) => (
<Menu.Item
key={option.key}
className={
Styles.menuItem + (option.key === initialValue ? " selected" : "")
}
onClick={() => handleConfirm(option.key)}
>
{option.text}
</Menu.Item>
))}
</Menu>
);
}, [inputValue, options]);
let renderContent, renderPopover;
switch (inputType) {
case "select":
renderContent = renderSelectionPopover;
break;
case "number":
case "text":
default:
renderContent = renderTextInputPopover;
}
return (
<PopoverOnSvg
trigger="click"
title={title}
renderContent={renderContent}
renderPopover={renderPopover}
visible={popoverVisible}
placement="bottom"
onVisibilityChange={handleVisibilityChange}
>
{children}
</PopoverOnSvg>
);
}
export default EditableContent;
@@ -0,0 +1,21 @@
.buttonGroup {
margin: 8px 0 4px 0;
width: 300px;
max-width: 100%;
text-align: right;
}
.button {
margin-right: 8px;
}
.menu {
max-height: 320px;
overflow: auto;
}
.menuItem:hover {
background-color: whitesmoke;
color: black !important;
user-select: none;
}
:global(.selected).menuItem {
background-color: whitesmoke;
}
+275
View File
@@ -0,0 +1,275 @@
import React, { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Styles from "./index.module.css";
// ENHANCE: smart placement
function PopoverOnSvg({
children,
renderContent,
renderPopover,
darkMode,
globalClassName,
offset,
placement,
style,
title,
trigger,
visible,
autoHide,
// TODO: complete this
showArrow,
onVisibilityChange,
}) {
const containerRef = useRef();
const triggerBoxRef = useRef({
x: 0,
y: 0,
width: 0,
height: 0,
top: 0,
bottom: 0,
left: 0,
right: 0,
});
const initFlagRef = useRef(false);
const triggerRef = useRef();
const popoverRef = useRef();
// initially, always set innerVisible to false. If the prop *visible* is
// 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 [, forceUpdate] = useState();
const popoverClassNames = [Styles.popover, globalClassName].filter(Boolean);
const arrowClassNames = [Styles.arrow];
const headerClassNames = [Styles.header];
let offsetX = (offset && offset.x) || 0;
let offsetY = (offset && offset.y) || 0;
let mergedStyles = {
transform: "",
};
const arrowStyles = {};
placement = placement.toLowerCase();
const makeOffset = function (x, y = 0) {
offsetX += x;
offsetY += y;
};
const changeVisibility = useCallback(
function (shouldShow) {
shouldShow = shouldShow ?? !innerVisible;
let newVisibility = visible === undefined ? shouldShow : visible;
if (newVisibility) {
if (!triggerRef.current) {
setTimeout(changeVisibility, 0);
return;
}
// re-get box of trigger element
triggerBoxRef.current = triggerRef.current.getBoundingClientRect();
}
setInnerVisible(newVisibility);
// FIXME: visible由false变为true时React Warning: Cannot update a component (`EditableContent`) while rendering a different component (`PopoverOnSvg`).
onVisibilityChange && onVisibilityChange(shouldShow);
},
[visible, innerVisible, onVisibilityChange]
);
const handleContextMenu = useCallback(
(ev) => {
// TODO: working
console.log(ev.pageX, ev.pageY)
ev.preventDefault();
changeVisibility(true);
},
[changeVisibility]
);
useEffect(() => {
initFlagRef.current = true;
}, []);
useEffect(() => {
if (autoHide === false) {
return;
}
const handler = (ev) => {
if (ev.type === "keydown" && ev.key === "Escape") {
changeVisibility(false);
} else if (ev.type === "mouseup") {
for (let target = ev.target; target; target = target.parentElement) {
if (target === popoverRef.current) {
return;
} else if (target === triggerRef.current) {
if (trigger === "context" && ev.button !== 2) {
continue;
} else if (trigger === "click" && ev.button !== 0) {
continue;
}
return;
}
}
changeVisibility(false);
}
};
if (innerVisible) {
document.addEventListener("mouseup", handler);
document.addEventListener("keydown", handler);
}
return () => {
document.removeEventListener("mouseup", handler);
document.removeEventListener("keydown", handler);
};
}, [innerVisible, autoHide, changeVisibility]);
if (visible !== undefined && visible !== innerVisible) {
if (initFlagRef.current) {
changeVisibility();
} else {
setTimeout(() => {
// this is the first-time render, trigger dom has not mounted, so we
// have to delay the rendering
forceUpdate([]);
}, 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);
}
}
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");
headerClassNames.push("dark");
}
mergedStyles = Object.assign(mergedStyles, style);
return (
<>
[]
<g
type="noop"
ref={triggerRef}
onClick={trigger === "click" ? () => changeVisibility() : undefined}
onMouseEnter={
trigger === "hover" ? () => changeVisibility(true) : undefined
}
onMouseLeave={
trigger === "hover" ? () => changeVisibility(false) : undefined
}
onContextMenu={trigger === "context" ? handleContextMenu : undefined}
>
{children}
</g>
{innerVisible &&
createPortal(
<div
ref={popoverRef}
className={popoverClassNames.join(" ")}
style={mergedStyles}
>
{renderPopover ? (
typeof renderPopover === "function" ? (
renderPopover()
) : (
renderPopover
)
) : (
<>
<div
className={arrowClassNames.join(" ")}
style={arrowStyles}
></div>
{title && (
<header className={headerClassNames.join(" ")}>
{title}
</header>
)}
<div className={title ? Styles.content : null}>
{typeof renderContent === "function"
? renderContent()
: renderContent ?? null}
</div>
</>
)}
</div>,
containerRef.current
)}
</>
);
}
PopoverOnSvg.defaultProps = {
trigger: "click",
placement: "top",
};
export default React.memo(PopoverOnSvg);
@@ -0,0 +1,55 @@
.popover {
position: absolute;
border-radius: 4px;
box-shadow: 0 1px 8px rgba(0, 0, 0, 0.15);
background-color: white;
min-width: 32px;
}
:global(.dark).popover {
background-color: black;
color: white;
}
.arrow {
display: block;
position: absolute;
z-index: -1;
border: 8px solid white;
background-color: white;
width: 8px;
height: 8px;
}
:global(.dark).arrow {
border-color: black;
background-color: black;
}
:global(.left).arrow {
right: -12px;
transform: translate(-50%, -50%) rotate(135deg);
box-shadow: -2px -2px 6px -2px rgba(0, 0, 0, 0.06);
}
:global(.right).arrow {
left: 4px;
transform: translate(-50%, -50%) rotate(-45deg);
box-shadow: -2px -2px 6px -2px rgba(0, 0, 0, 0.06);
}
:global(.top).arrow {
bottom: -12px;
transform: translate(-50%, -50%) rotate(45deg);
box-shadow: 3px 3px 7px -2px rgba(0, 0, 0, 0.07);
}
:global(.bottom).arrow {
top: 4px;
transform: translate(-50%, -50%) rotate(45deg);
box-shadow: -3px -3px 8px -2px rgba(0, 0, 0, 0.07);
}
.content {
padding: 4px 8px;
}
.header {
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
padding: 4px 8px;
line-height: 22px;
}
:global(.dark).header {
border-bottom: 1px solid rgba(255, 255, 255, 0.35);
}
+7 -6
View File
@@ -1,14 +1,15 @@
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import React from "react";
import ReactDOM from "react-dom";
import "antd/dist/antd.css";
import App from "./App";
import reportWebVitals from "./reportWebVitals";
import "./index.css";
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
document.getElementById("root")
);
// If you want to start measuring performance in your app, pass a function
+17
View File
@@ -0,0 +1,17 @@
import React from "react";
import { observable } from "mobx";
let globalStore = observable({
canvasWidth: 1024,
canvasHeight: 1448,
title: "简谱",
tone: "♭D",
marginHorizontal: 32,
beat: [4, 4],
speed: 75,
authors: ["Haven Mattuy 制谱", "杨瑞光 作词"],
data: {},
});
export default globalStore;
export const GlobalContext = React.createContext(globalStore);
+6
View File
@@ -0,0 +1,6 @@
// 为了解决svg text不渲染连续空格,对html转义并替换空格为&nbsp;
export default function escapeHtml(text) {
const span = document.createElement("span");
span.appendChild(document.createTextNode(text));
return span.innerHTML.replace(/\s/g, "&nbsp;");
}
+19
View File
@@ -0,0 +1,19 @@
import { observer } from "mobx-react-lite";
import store from "../../store/global";
import Styles from "./index.module.css";
function Canvas({ children, ...props }) {
return (
<svg
xmlns=" http://www.w3.org/2000/svg"
{...props}
className={Styles.svg}
width={store.canvasWidth}
height={store.canvasHeight}
>
{children}
</svg>
);
}
export default observer(Canvas);
+7
View File
@@ -0,0 +1,7 @@
.svg {
border: 1px solid ghostwhite;
box-shadow: ghostwhite 0px 0px 10px;
}
.svg :global(text) {
user-select: none;
}
+13
View File
@@ -0,0 +1,13 @@
import Canvas from "../Canvas";
import Header from "../Header";
import Styles from "./index.module.css";
export default function Editor() {
return (
<div className={Styles.container}>
<Canvas>
<Header />
</Canvas>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
.container {
background-color: white;
height: 100%;
display: flex;
justify-content: center;
padding-top: 8px;
}
+110
View File
@@ -0,0 +1,110 @@
import { action } from "mobx";
import { message } from "antd";
import { observer } from "mobx-react-lite";
import EditableContent from "../../../component/EditableContent";
import store from "../../../store/global";
import Row from "../../Row";
import Text from "../../Text";
const tones = [
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"♭A",
"♭B",
"♭C",
"♭D",
"♭E",
"♭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;
});
const handleChangeSpeed = action(function (value) {
store.speed = value;
});
const handleChangeBeat = action(function (value) {
const beat = String(value).split("/");
if (beat.length !== 2) {
message.error("请以【*/*】的格式输入节拍!");
return false;
}
const [c, t] = [parseInt(beat[0], 10), parseInt(beat[1], 10)];
if (c > 0 && t > 0) {
store.beat = [c, t];
} else {
message.error("请输入大于0的拍数和时值!");
return false;
}
});
function LeftInfoBlock() {
return (
<>
<EditableContent
inputType="select"
initialValue={store.tone}
options={tones}
onChange={handleChangeTone}
>
<Row type="tone" editable offsetX={store.marginHorizontal} offsetY="64">
<Text>1&nbsp;&nbsp;= </Text>
{store.tone.startsWith("♭") && (
<Text x="25" y="-2" fontSize="12">
</Text>
)}
<Text editable x={store.tone.startsWith("♭") ? 34 : 28}>
{store.tone.at(-1)}
</Text>
</Row>
</EditableContent>
<EditableContent
title="节拍:"
inputType="number"
initialValue={store.beat.join("/")}
onChange={handleChangeBeat}
>
<Row
type="beat"
editable
offsetX={store.marginHorizontal + 64}
offsetY="64"
>
<Text x="0" y="-8" textAnchor="middle">
{store.beat[0]}
</Text>
<Text x="0" y="12" textAnchor="middle">
{store.beat[1]}
</Text>
<line x1="-8" y1="8" x2="8" y2="8" stroke="black" />
</Row>
</EditableContent>
<EditableContent
title="速度(bps):"
inputType="number"
initialValue={store.speed}
onChange={handleChangeSpeed}
>
<Row
editable
type="speed"
offsetX={store.marginHorizontal}
offsetY="86"
>
<Text x="-4"></Text>
<Text x="14">=&nbsp;&nbsp;{store.speed}</Text>
</Row>
</EditableContent>
</>
);
}
export default observer(LeftInfoBlock);
+52
View File
@@ -0,0 +1,52 @@
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 Row from "../../Row";
import Text from "../../Text";
const handleChangeAuthor = action((index, value) => {
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="64"
>
<rect
x="-100"
y="-4"
width="100"
height={store.authors.length * 22}
fill="transparent"
></rect>
{store.authors.map((author, i) => (
<EditableContent
key={author}
title="作者信息:"
initialValue={author}
onChange={handleChangeAuthor.bind(null, i)}
>
<Text editable y={i * 22} textAnchor="end">
{author}
</Text>
</EditableContent>
))}
</Row>
</PopoverOnSvg>
);
}
export default observer(RightInfoBlock);
+34
View File
@@ -0,0 +1,34 @@
import { action } from "mobx";
import { observer } from "mobx-react-lite";
import EditableContent from "../../../component/EditableContent";
import store from "../../../store/global";
import Row from "../../Row";
import Text from "../../Text";
const handleChangeTitle = action((value) => {
store.title = value;
});
function Title() {
return (
<Row type="title" offsetY="32">
<EditableContent
title="歌曲名称:"
initialValue={store.title}
onChange={handleChangeTitle}
>
<Text
editable
x="50%"
fontSize="32"
fill="black"
stroke="none"
textAnchor="middle"
>
{store.title}
</Text>
</EditableContent>
</Row>
);
}
export default observer(Title);
+16
View File
@@ -0,0 +1,16 @@
import React from "react";
import LeftInfoBlock from "./LeftInfoBlock";
import RightInfoBlock from "./RightInfoBlock";
import Title from "./Title";
function Header() {
return (
<>
<Title></Title>
<LeftInfoBlock></LeftInfoBlock>
<RightInfoBlock></RightInfoBlock>
</>
);
}
export default React.memo(Header);
+10
View File
@@ -0,0 +1,10 @@
import Row from "../Row";
import Text from "../Text";
export default function Notation(...props) {
return (
<Row type="notation" {...props}>
<Text y="100">1</Text>
</Row>
);
}
+28
View File
@@ -0,0 +1,28 @@
export default function Row({
children,
type,
editable,
offset,
offsetX,
offsetY,
...props
}) {
offsetX = (offset?.x ?? offsetX) | 0;
offsetY = (offset?.y ?? offsetY) | 0;
return (
<g
type={type}
transform={`translate(${offsetX},${offsetY})`}
style={
editable
? {
cursor: "pointer",
}
: null
}
{...props}
>
{children}
</g>
);
}
+45
View File
@@ -0,0 +1,45 @@
import escapeHtml from "../../util/html-escape";
export default function Text({ children, editable, ...props }) {
if (typeof children === "string") {
return (
<text
x="0"
y="0"
style={
editable
? {
cursor: "pointer",
}
: null
}
dominantBaseline="hanging"
stroke="none"
fontWeight="bold"
fill="black"
dangerouslySetInnerHTML={{ __html: escapeHtml(children) }}
{...props}
></text>
);
}
return (
<text
x="0"
y="0"
style={
editable
? {
cursor: "pointer",
}
: null
}
dominantBaseline="hanging"
stroke="none"
fontWeight="bold"
fill="black"
{...props}
>
{children}
</text>
);
}