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",
+15 -38
View File
@@ -1,43 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<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"
/>
<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.
<html lang="zh_CN">
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>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</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.
<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" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>简单简谱编辑器</title>
</head>
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
<body>
<noscript>要使用本程序,请启用JavaScript。</noscript>
<div id="root"></div>
</body>
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>
);
}