feat: sharpness (#1931)

* feat: sharpness

* feat: fill sharp lines, et al.

* fix: rotated positioning

* chore: simplify path with Q

* fix: hit test inside sharp elements

* make sharp / round buttons work properly

* fix tsc tests

* update snapshots

* update snapshots

* fix: sharp arrow creation error

* fix merge and test

* avoid type assertion

* remove duplicate helper

Co-authored-by: dwelle <luzar.david@gmail.com>
This commit is contained in:
Daishi Kato 2020-08-15 00:59:43 +09:00 committed by GitHub
parent 930813387b
commit 41cb1fbeba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 841 additions and 42 deletions

View File

@ -8,6 +8,8 @@ import {
import {
getCommonAttributeOfSelectedElements,
isSomeElementSelected,
getTargetElement,
canChangeSharpness,
} from "../scene";
import { ButtonSelect } from "../components/ButtonSelect";
import {
@ -15,6 +17,7 @@ import {
redrawTextBoundingBox,
getNonDeletedElements,
} from "../element";
import { isLinearElement, isLinearElementType } from "../element/typeChecks";
import { ColorPicker } from "../components/ColorPicker";
import { AppState } from "../../src/types";
import { t } from "../i18n";
@ -450,3 +453,59 @@ export const actionChangeTextAlign = register({
</fieldset>
),
});
export const actionChangeSharpness = register({
name: "changeSharpness",
perform: (elements, appState, value) => {
const targetElements = getTargetElement(
getNonDeletedElements(elements),
appState,
);
const shouldUpdateForNonLinearElements = targetElements.length
? targetElements.every((e) => !isLinearElement(e))
: !isLinearElementType(appState.elementType);
const shouldUpdateForLinearElements = targetElements.length
? targetElements.every(isLinearElement)
: isLinearElementType(appState.elementType);
return {
elements: changeProperty(elements, appState, (el) =>
newElementWith(el, {
strokeSharpness: value,
}),
),
appState: {
...appState,
currentItemStrokeSharpness: shouldUpdateForNonLinearElements
? value
: appState.currentItemStrokeSharpness,
currentItemLinearStrokeSharpness: shouldUpdateForLinearElements
? value
: appState.currentItemLinearStrokeSharpness,
},
commitToHistory: true,
};
},
PanelComponent: ({ elements, appState, updateData }) => (
<fieldset>
<legend>{t("labels.edges")}</legend>
<ButtonSelect
group="edges"
options={[
{ value: "sharp", text: t("labels.sharp") },
{ value: "round", text: t("labels.round") },
]}
value={getFormValue(
elements,
appState,
(element) => element.strokeSharpness,
(canChangeSharpness(appState.elementType) &&
(isLinearElementType(appState.elementType)
? appState.currentItemLinearStrokeSharpness
: appState.currentItemStrokeSharpness)) ||
null,
)}
onChange={(value) => updateData(value)}
/>
</fieldset>
),
});

View File

@ -63,7 +63,8 @@ export type ActionName =
| "group"
| "ungroup"
| "goToCollaborator"
| "addToLibrary";
| "addToLibrary"
| "changeSharpness";
export interface Action {
name: ActionName;

View File

@ -36,6 +36,8 @@ export const getDefaultAppState = (): Omit<
currentItemFontSize: DEFAULT_FONT_SIZE,
currentItemFontFamily: DEFAULT_FONT_FAMILY,
currentItemTextAlign: DEFAULT_TEXT_ALIGN,
currentItemStrokeSharpness: "sharp",
currentItemLinearStrokeSharpness: "round",
viewBackgroundColor: oc.white,
scrollX: 0 as FlooredNumber,
scrollY: 0 as FlooredNumber,
@ -96,6 +98,8 @@ const APP_STATE_STORAGE_CONF = (<
currentItemStrokeStyle: { browser: true, export: false },
currentItemStrokeWidth: { browser: true, export: false },
currentItemTextAlign: { browser: true, export: false },
currentItemStrokeSharpness: { browser: true, export: false },
currentItemLinearStrokeSharpness: { browser: true, export: false },
cursorButton: { browser: true, export: false },
cursorX: { browser: true, export: false },
cursorY: { browser: true, export: false },

View File

@ -164,6 +164,7 @@ export function renderSpreadsheet(
strokeStyle: appState.currentItemStrokeStyle,
roughness: appState.currentItemRoughness,
opacity: appState.currentItemOpacity,
strokeSharpness: appState.currentItemStrokeSharpness,
text: min.toLocaleString(),
fontSize: 16,
fontFamily: appState.currentItemFontFamily,
@ -181,6 +182,7 @@ export function renderSpreadsheet(
strokeStyle: appState.currentItemStrokeStyle,
roughness: appState.currentItemRoughness,
opacity: appState.currentItemOpacity,
strokeSharpness: appState.currentItemStrokeSharpness,
text: max.toLocaleString(),
fontSize: 16,
fontFamily: appState.currentItemFontFamily,
@ -207,6 +209,7 @@ export function renderSpreadsheet(
strokeStyle: appState.currentItemStrokeStyle,
roughness: appState.currentItemRoughness,
opacity: appState.currentItemOpacity,
strokeSharpness: appState.currentItemStrokeSharpness,
});
});
@ -226,6 +229,7 @@ export function renderSpreadsheet(
strokeStyle: appState.currentItemStrokeStyle,
roughness: appState.currentItemRoughness,
opacity: appState.currentItemOpacity,
strokeSharpness: appState.currentItemStrokeSharpness,
fontSize: 16,
fontFamily: appState.currentItemFontFamily,
textAlign: "center",
@ -247,6 +251,7 @@ export function renderSpreadsheet(
strokeStyle: appState.currentItemStrokeStyle,
roughness: appState.currentItemRoughness,
opacity: appState.currentItemOpacity,
strokeSharpness: appState.currentItemStrokeSharpness,
fontSize: 20,
fontFamily: appState.currentItemFontFamily,
textAlign: "center",

View File

@ -2,7 +2,13 @@ import React from "react";
import { AppState } from "../types";
import { ExcalidrawElement } from "../element/types";
import { ActionManager } from "../actions/manager";
import { hasBackground, hasStroke, hasText, getTargetElement } from "../scene";
import {
hasBackground,
hasStroke,
canChangeSharpness,
hasText,
getTargetElement,
} from "../scene";
import { t } from "../i18n";
import { SHAPES } from "../shapes";
import { ToolButton } from "./ToolButton";
@ -50,6 +56,11 @@ export const SelectedShapeActions = ({
</>
)}
{(canChangeSharpness(elementType) ||
targetElements.some((element) => canChangeSharpness(element.type))) && (
<>{renderAction("changeSharpness")}</>
)}
{(hasText(elementType) ||
targetElements.some((element) => hasText(element.type))) && (
<>

View File

@ -1057,6 +1057,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
strokeSharpness: this.state.currentItemStrokeSharpness,
text: text,
fontSize: this.state.currentItemFontSize,
fontFamily: this.state.currentItemFontFamily,
@ -1771,6 +1772,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
strokeSharpness: this.state.currentItemStrokeSharpness,
text: "",
fontSize: this.state.currentItemFontSize,
fontFamily: this.state.currentItemFontFamily,
@ -2672,6 +2674,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
strokeSharpness: this.state.currentItemLinearStrokeSharpness,
});
this.setState((prevState) => ({
selectedElementIds: {
@ -2719,6 +2722,7 @@ class App extends React.Component<ExcalidrawProps, AppState> {
strokeStyle: this.state.currentItemStrokeStyle,
roughness: this.state.currentItemRoughness,
opacity: this.state.currentItemOpacity,
strokeSharpness: this.state.currentItemStrokeSharpness,
});
if (element.type === "selection") {

View File

@ -6,6 +6,7 @@ import {
import { AppState } from "../types";
import { DataState } from "./types";
import { isInvisiblySmallElement, getNormalizedDimensions } from "../element";
import { isLinearElementType } from "../element/typeChecks";
import { randomId } from "../random";
import {
FONT_FAMILY,
@ -49,6 +50,9 @@ function migrateElementWithProperties<T extends ExcalidrawElement>(
height: element.height || 0,
seed: element.seed ?? 1,
groupIds: element.groupIds ?? [],
strokeSharpness:
element.strokeSharpness ??
(isLinearElementType(element.type) ? "round" : "sharp"),
boundElementIds: element.boundElementIds ?? [],
};

View File

@ -165,6 +165,9 @@ export const getArrowPoints = (
shape: Drawable[],
) => {
const ops = getCurvePathOps(shape[0]);
if (ops.length < 1) {
return null;
}
const data = ops[ops.length - 1].data;
const p3 = [data[4], data[5]] as Point;
@ -339,10 +342,13 @@ export const getResizedElementAbsoluteCoords = (
);
const gen = rough.generator();
const curve = gen.curve(
points as [number, number][],
generateRoughOptions(element),
);
const curve =
element.strokeSharpness === "sharp"
? gen.linearPath(
points as [number, number][],
generateRoughOptions(element),
)
: gen.curve(points as [number, number][], generateRoughOptions(element));
const ops = getCurvePathOps(curve);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
return [
@ -356,13 +362,17 @@ export const getResizedElementAbsoluteCoords = (
export const getElementPointsCoords = (
element: ExcalidrawLinearElement,
points: readonly (readonly [number, number])[],
sharpness: ExcalidrawElement["strokeSharpness"],
): [number, number, number, number] => {
// This might be computationally heavey
const gen = rough.generator();
const curve = gen.curve(
points as [number, number][],
generateRoughOptions(element),
);
const curve =
sharpness === "sharp"
? gen.linearPath(
points as [number, number][],
generateRoughOptions(element),
)
: gen.curve(points as [number, number][], generateRoughOptions(element));
const ops = getCurvePathOps(curve);
const [minX, minY, maxX, maxY] = getMinMaxXYFromCurvePathOps(ops);
return [

View File

@ -267,7 +267,7 @@ const hitTestLinear = (args: HitTestArgs): boolean => {
if (args.check === isInsideCheck) {
const hit = shape.some((subshape) =>
hitTestCurveInside(subshape, relX, relY),
hitTestCurveInside(subshape, relX, relY, element.strokeSharpness),
);
if (hit) {
return true;
@ -688,22 +688,33 @@ const pointInBezierEquation = (
return false;
};
const hitTestCurveInside = (drawable: Drawable, x: number, y: number) => {
const hitTestCurveInside = (
drawable: Drawable,
x: number,
y: number,
sharpness: ExcalidrawElement["strokeSharpness"],
) => {
const ops = getCurvePathOps(drawable);
const points: Point[] = [];
let odd = false; // select one line out of double lines
for (const operation of ops) {
if (operation.op === "move") {
if (points.length) {
break;
odd = !odd;
if (odd) {
points.push([operation.data[0], operation.data[1]]);
}
points.push([operation.data[0], operation.data[1]]);
} else if (operation.op === "bcurveTo") {
points.push([operation.data[0], operation.data[1]]);
points.push([operation.data[2], operation.data[3]]);
points.push([operation.data[4], operation.data[5]]);
if (odd) {
points.push([operation.data[0], operation.data[1]]);
points.push([operation.data[2], operation.data[3]]);
points.push([operation.data[4], operation.data[5]]);
}
}
}
if (points.length >= 4) {
if (sharpness === "sharp") {
return isPointInPolygon(points, x, y);
}
const polygonPoints = pointsOnBezierCurves(points as any, 10, 5);
return isPointInPolygon(polygonPoints, x, y);
}

View File

@ -508,8 +508,16 @@ export class LinearElementEditor {
});
}
const nextCoords = getElementPointsCoords(element, nextPoints);
const prevCoords = getElementPointsCoords(element, points);
const nextCoords = getElementPointsCoords(
element,
nextPoints,
element.strokeSharpness || "round",
);
const prevCoords = getElementPointsCoords(
element,
points,
element.strokeSharpness || "round",
);
const nextCenterX = (nextCoords[0] + nextCoords[2]) / 2;
const nextCenterY = (nextCoords[1] + nextCoords[3]) / 2;
const prevCenterX = (prevCoords[0] + prevCoords[2]) / 2;

View File

@ -31,6 +31,7 @@ it("clones arrow element", () => {
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
strokeSharpness: "round",
roughness: 1,
opacity: 100,
});
@ -75,6 +76,7 @@ it("clones text element", () => {
fillStyle: "hachure",
strokeWidth: 1,
strokeStyle: "solid",
strokeSharpness: "round",
roughness: 1,
opacity: 100,
text: "hello",

View File

@ -46,6 +46,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
height = 0,
angle = 0,
groupIds = [],
strokeSharpness,
boundElementIds = null,
...rest
}: ElementConstructorOpts & Omit<Partial<ExcalidrawGenericElement>, "type">,
@ -65,6 +66,7 @@ const _newElementBase = <T extends ExcalidrawElement>(
roughness,
opacity,
groupIds,
strokeSharpness,
seed: rest.seed ?? randomInteger(),
version: rest.version || 1,
versionNonce: rest.versionNonce ?? 0,

View File

@ -12,6 +12,7 @@ type _ExcalidrawElementBase = Readonly<{
fillStyle: string;
strokeWidth: number;
strokeStyle: "solid" | "dashed" | "dotted";
strokeSharpness: "round" | "sharp";
roughness: number;
opacity: number;
width: number;

View File

@ -25,6 +25,9 @@
"sloppiness": "Sloppiness",
"opacity": "Opacity",
"textAlign": "Text align",
"edges": "Edges",
"sharp": "Sharp",
"round": "Round",
"fontSize": "Font size",
"fontFamily": "Font family",
"onlySelected": "Only selected",

View File

@ -240,14 +240,27 @@ const generateElementShape = (
switch (element.type) {
case "rectangle":
shape = generator.rectangle(
0,
0,
element.width,
element.height,
generateRoughOptions(element),
);
if (element.strokeSharpness === "round") {
const w = element.width;
const h = element.height;
const r = Math.min(w, h) * 0.25;
shape = generator.path(
`M ${r} 0 L ${w - r} 0 Q ${w} 0, ${w} ${r} L ${w} ${
h - r
} Q ${w} ${h}, ${w - r} ${h} L ${r} ${h} Q 0 ${h}, 0 ${
h - r
} L 0 ${r} Q 0 0, ${r} 0`,
generateRoughOptions(element),
);
} else {
shape = generator.rectangle(
0,
0,
element.width,
element.height,
generateRoughOptions(element),
);
}
break;
case "diamond": {
const [
@ -291,24 +304,37 @@ const generateElementShape = (
// curve is always the first element
// this simplifies finding the curve for an element
shape = [generator.curve(points as [number, number][], options)];
if (element.strokeSharpness === "sharp") {
if (options.fill) {
shape = [generator.polygon(points as [number, number][], options)];
} else {
shape = [
generator.linearPath(points as [number, number][], options),
];
}
} else {
shape = [generator.curve(points as [number, number][], options)];
}
// add lines only in arrow
if (element.type === "arrow") {
const [x2, y2, x3, y3, x4, y4] = getArrowPoints(element, shape);
// for dotted arrows caps, reduce gap to make it more legible
if (element.strokeStyle === "dotted") {
options.strokeLineDash = [3, 4];
// for solid/dashed, keep solid arrow cap
} else {
delete options.strokeLineDash;
const arrowPoints = getArrowPoints(element, shape);
if (arrowPoints) {
const [x2, y2, x3, y3, x4, y4] = arrowPoints;
// for dotted arrows caps, reduce gap to make it more legible
if (element.strokeStyle === "dotted") {
options.strokeLineDash = [3, 4];
// for solid/dashed, keep solid arrow cap
} else {
delete options.strokeLineDash;
}
shape.push(
...[
generator.line(x3, y3, x2, y2, options),
generator.line(x4, y4, x2, y2, options),
],
);
}
shape.push(
...[
generator.line(x3, y3, x2, y2, options),
generator.line(x4, y4, x2, y2, options),
],
);
}
break;
}

View File

@ -20,6 +20,12 @@ export const hasStroke = (type: string) =>
type === "draw" ||
type === "line";
export const canChangeSharpness = (type: string) =>
type === "rectangle" ||
type === "arrow" ||
type === "draw" ||
type === "line";
export const hasText = (type: string) => type === "text";
export const getElementAtPosition = (

View File

@ -165,5 +165,6 @@ const getWatermarkElement = (maxX: number, maxY: number) => {
strokeStyle: "solid",
roughness: 1,
opacity: 100,
strokeSharpness: "sharp",
});
};

View File

@ -10,6 +10,7 @@ export { normalizeScroll, calculateScrollCenter } from "./scroll";
export {
hasBackground,
hasStroke,
canChangeSharpness,
getElementAtPosition,
getElementContainingPosition,
hasText,

View File

@ -29,6 +29,7 @@ Object {
"seed": 337897,
"startBinding": null,
"strokeColor": "#000000",
"strokeSharpness": "round",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "arrow",
@ -56,6 +57,7 @@ Object {
"roughness": 1,
"seed": 337897,
"strokeColor": "#000000",
"strokeSharpness": "sharp",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "diamond",
@ -83,6 +85,7 @@ Object {
"roughness": 1,
"seed": 337897,
"strokeColor": "#000000",
"strokeSharpness": "sharp",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "ellipse",
@ -121,6 +124,7 @@ Object {
"seed": 337897,
"startBinding": null,
"strokeColor": "#000000",
"strokeSharpness": "round",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "line",
@ -148,6 +152,7 @@ Object {
"roughness": 1,
"seed": 337897,
"strokeColor": "#000000",
"strokeSharpness": "sharp",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",

View File

@ -14,6 +14,7 @@ Object {
"roughness": 1,
"seed": 401146281,
"strokeColor": "#000000",
"strokeSharpness": "sharp",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
@ -39,6 +40,7 @@ Object {
"roughness": 1,
"seed": 337897,
"strokeColor": "#000000",
"strokeSharpness": "sharp",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
@ -64,6 +66,7 @@ Object {
"roughness": 1,
"seed": 337897,
"strokeColor": "#000000",
"strokeSharpness": "sharp",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",

View File

@ -34,6 +34,7 @@ Object {
"seed": 337897,
"startBinding": null,
"strokeColor": "#000000",
"strokeSharpness": "round",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "arrow",
@ -79,6 +80,7 @@ Object {
"seed": 337897,
"startBinding": null,
"strokeColor": "#000000",
"strokeSharpness": "round",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "line",

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,7 @@ Object {
"roughness": 1,
"seed": 337897,
"strokeColor": "#000000",
"strokeSharpness": "sharp",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",
@ -39,6 +40,7 @@ Object {
"roughness": 1,
"seed": 337897,
"strokeColor": "#000000",
"strokeSharpness": "sharp",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",

View File

@ -27,6 +27,7 @@ Object {
"seed": 337897,
"startBinding": null,
"strokeColor": "#000000",
"strokeSharpness": "round",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "arrow",
@ -65,6 +66,7 @@ Object {
"seed": 337897,
"startBinding": null,
"strokeColor": "#000000",
"strokeSharpness": "round",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "line",
@ -90,6 +92,7 @@ Object {
"roughness": 1,
"seed": 337897,
"strokeColor": "#000000",
"strokeSharpness": "sharp",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "diamond",
@ -115,6 +118,7 @@ Object {
"roughness": 1,
"seed": 337897,
"strokeColor": "#000000",
"strokeSharpness": "sharp",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "ellipse",
@ -140,6 +144,7 @@ Object {
"roughness": 1,
"seed": 337897,
"strokeColor": "#000000",
"strokeSharpness": "sharp",
"strokeStyle": "solid",
"strokeWidth": 1,
"type": "rectangle",

View File

@ -37,6 +37,7 @@ const populateElements = (
fillStyle: h.state.currentItemFillStyle,
strokeWidth: h.state.currentItemStrokeWidth,
strokeStyle: h.state.currentItemStrokeStyle,
strokeSharpness: h.state.currentItemStrokeSharpness,
roughness: h.state.currentItemRoughness,
opacity: h.state.currentItemOpacity,
});

View File

@ -56,6 +56,8 @@ export type AppState = {
currentItemFontFamily: FontFamily;
currentItemFontSize: number;
currentItemTextAlign: TextAlign;
currentItemStrokeSharpness: ExcalidrawElement["strokeSharpness"];
currentItemLinearStrokeSharpness: ExcalidrawElement["strokeSharpness"];
viewBackgroundColor: string;
scrollX: FlooredNumber;
scrollY: FlooredNumber;