Merge branch 'main' into migrate-tokens

This commit is contained in:
Oleg Isonen committed 2022-11-10 16:39:27 +01:00
commit 57b8bc92c2
17 files changed
+2463 -65

No files matched your search

@@ -9,6 +9,7 @@ import {
} from "lexical";
import { $createLinkNode, LinkNode } from "@lexical/link";
import type { ChildrenUpdates, Instance } from "@webstudio-is/react-sdk";
import { $isSpanNode, $setNodeSpan } from "./toolbar-connector";
// Map<nodeKey, Instance>
export type Refs = Map<string, Instance>;
@@ -44,6 +45,16 @@ const $writeUpdates = (
// and add ref suffix to distinct styling on one node key
const text = child.getTextContent();
let parentUpdates = updates;
if ($isSpanNode(child)) {
const id = refs.get(`${child.getKey()}:span`)?.id;
const update: ChildrenUpdates[number] = {
id,
component: "Span",
children: [],
};
parentUpdates.push(update);
parentUpdates = update.children;
}
if (child.hasFormat("bold")) {
const id = refs.get(`${child.getKey()}:bold`)?.id;
const update: ChildrenUpdates[number] = {
@@ -98,6 +109,18 @@ const $writeLexical = (
parent.append(linkNode);
$writeLexical(linkNode, child.children, refs);
}
if (child.component === "Span") {
let textNode;
if (parent instanceof TextNode) {
textNode = parent;
} else {
textNode = $createTextNode("");
parent.append(textNode);
}
$setNodeSpan(textNode);
refs.set(`${textNode.getKey()}:span`, child);
$writeLexical(textNode, child.children, refs);
}
if (child.component === "Bold") {
let textNode;
if (parent instanceof TextNode) {
@@ -17,11 +17,13 @@ export const Basic: ComponentStory<typeof TextEditor> = ({ onChange }) => {
const [isBold, setIsBold] = useState(false);
const [isItalic, setIsItalic] = useState(false);
const [isLink, setIsLink] = useState(false);
const [isSpan, setIsSpan] = useState(false);
useSubscribe("showTextToolbar", (event) => {
setIsEnabled(true);
setIsBold(event.isBold);
setIsItalic(event.isItalic);
setIsLink(event.isLink);
setIsSpan(event.isSpan);
});
useSubscribe("hideTextToolbar", () => {
setIsEnabled(false);
@@ -52,6 +54,13 @@ export const Basic: ComponentStory<typeof TextEditor> = ({ onChange }) => {
>
Link
</button>
<button
disabled={isEnabled === false}
style={{ fontWeight: isSpan ? "bold" : "normal" }}
onClick={() => publish({ type: "formatTextToolbar", payload: "span" })}
>
span
</button>
<Box
css={{
"& > div": {
@@ -25,15 +25,13 @@ const NodeConnector = ({
}) => {
const [breakpoints] = useBreakpoints();
useEffect(() => {
const [key, style] = nodeKey.split(":");
// extract key from stored key:style format
const [key] = nodeKey.split(":");
const element = editor.getElementByKey(key);
if (element) {
const className = css(toCss(instance.cssRules, breakpoints))().toString();
// assume only styles are important while editing text
element.className = className;
if (style === "italic") {
element.style.fontStyle = "italic";
}
}
}, [editor, nodeKey, instance, breakpoints]);
return null;
@@ -1,37 +1,63 @@
import { useCallback, useEffect } from "react";
import {
type RangeSelection,
type TextNode,
$getSelection,
$isRangeSelection,
$isTextNode,
FORMAT_TEXT_COMMAND,
SELECTION_CHANGE_COMMAND,
COMMAND_PRIORITY_LOW,
} from "lexical";
import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
import { $isAtNodeEnd } from "@lexical/selection";
import { $getNearestNodeOfType } from "@lexical/utils";
import { $patchStyleText } from "@lexical/selection";
import { LinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import { useSubscribe, publish } from "~/shared/pubsub";
const getSelectedNode = (selection: RangeSelection) => {
const anchor = selection.anchor;
const focus = selection.focus;
const anchorNode = anchor.getNode();
const focusNode = focus.getNode();
if (anchorNode === focusNode) {
return anchorNode;
const spanTriggerName = "--style-node-trigger";
export const $isSpanNode = (node: TextNode) => {
return node.getStyle().includes(spanTriggerName);
};
export const $setNodeSpan = (node: TextNode) => {
return node.setStyle(`${spanTriggerName}:;`);
};
const $getSpanNodes = (selection: RangeSelection) => {
const nodes = selection.getNodes();
const spans: TextNode[] = [];
// check each TextNode within selection for existing span nodes
for (const node of nodes) {
if ($isTextNode(node) && $isSpanNode(node)) {
spans.push(node);
}
}
const isBackward = selection.isBackward();
if (isBackward) {
return $isAtNodeEnd(focus) ? anchorNode : focusNode;
} else {
return $isAtNodeEnd(anchor) ? focusNode : anchorNode;
return spans;
};
const $toggleSpan = () => {
const selection = $getSelection();
if ($isRangeSelection(selection)) {
const spans = $getSpanNodes(selection);
if (spans.length === 0) {
// lexical creates separate text node when style property do not match
$patchStyleText(selection, {
[spanTriggerName]: "",
});
} else {
// clear span nodes style
for (const node of spans) {
node.setStyle("");
}
}
}
};
const isSelectedLink = (selection: RangeSelection) => {
const node = getSelectedNode(selection);
const parent = node.getParent();
return $isLinkNode(parent) || $isLinkNode(node);
const $isSelectedLink = (selection: RangeSelection) => {
const [selectedNode] = selection.getNodes();
return $getNearestNodeOfType(selectedNode, LinkNode) != null;
};
export const ToolbarConnectorPlugin = () => {
@@ -50,10 +76,11 @@ export const ToolbarConnectorPlugin = () => {
const selectionRect = domRange.getBoundingClientRect();
const isBold = selection.hasFormat("bold");
const isItalic = selection.hasFormat("italic");
const isLink = isSelectedLink(selection);
const isLink = $isSelectedLink(selection);
const isSpan = $getSpanNodes(selection).length !== 0;
publish({
type: "showTextToolbar",
payload: { selectionRect, isBold, isItalic, isLink },
payload: { selectionRect, isBold, isItalic, isLink, isSpan },
});
} else {
publish({ type: "hideTextToolbar" });
@@ -99,7 +126,7 @@ export const ToolbarConnectorPlugin = () => {
let isLink = false;
editorState.read(() => {
const selection = $getSelection();
isLink = $isRangeSelection(selection) && isSelectedLink(selection);
isLink = $isRangeSelection(selection) && $isSelectedLink(selection);
});
if (isLink) {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
@@ -107,6 +134,11 @@ export const ToolbarConnectorPlugin = () => {
editor.dispatchCommand(TOGGLE_LINK_COMMAND, "https://");
}
}
if (type === "span") {
editor.update(() => {
$toggleSpan();
});
}
});
return null;
+3 -8
View File
@@ -36,7 +36,7 @@ import {
useRootInstance,
useTextEditingInstanceId,
} from "~/shared/nano-states";
import { useMeasure } from "~/shared/dom-hooks";
import { useMeasureInstance } from "~/canvas/shared/use-measure-instance";
import {
findInstanceByElement,
getInstanceElementById,
@@ -254,15 +254,10 @@ const publishRect = (rect: DOMRect) => {
export const usePublishSelectedInstanceDataRect = () => {
const [element] = useSelectedElement();
const [refCallback, rect] = useMeasure();
const rect = useMeasureInstance(element);
useEffect(() => {
// Disconnect observer when there is no element.
refCallback(element ?? null);
}, [element, refCallback]);
useEffect(() => {
if (rect !== undefined) publishRect(rect);
if (rect) publishRect(rect);
}, [rect]);
};
@@ -0,0 +1,47 @@
import { useCallback, useEffect, useMemo } from "react";
import { useMeasure, useRectState } from "~/shared/dom-hooks";
import { type PubsubMap, useSubscribeAll } from "~/shared/pubsub";
const useSubscribeTreeChange = (onChange: () => void, isEnabled: boolean) => {
const callback = useMemo(() => {
if (!isEnabled) {
return () => null;
}
return (type: keyof PubsubMap) => {
if (
type === "updateProps" ||
type === "deleteProp" ||
type === "insertInstance" ||
type === "deleteInstance" ||
type === "reparentInstance" ||
type === "updateStyle" ||
type.startsWith("previewStyle:")
) {
onChange();
}
};
}, [onChange, isEnabled]);
useSubscribeAll(callback);
};
// A version of useMeasure capable of measuring inlined elements, but works only within canvas.
export const useMeasureInstance = (element: HTMLElement | undefined) => {
const { canObserve, rect: baseRect } = useMeasure(element);
const [rect, setRect] = useRectState();
const handleChange = useCallback(() => {
setRect(element && element.getBoundingClientRect());
}, [element, setRect]);
useSubscribeTreeChange(handleChange, canObserve === false);
useEffect(() => {
if (canObserve === false) {
handleChange();
}
}, [handleChange, canObserve]);
return canObserve ? baseRect : rect;
};
@@ -6,14 +6,19 @@ import {
useTextToolbarState,
} from "~/designer/shared/nano-states";
import { ToggleGroup, type CSS } from "@webstudio-is/design-system";
import { FontBoldIcon, FontItalicIcon, Link2Icon } from "@webstudio-is/icons";
import {
FontBoldIcon,
FontItalicIcon,
Link2Icon,
BrushIcon,
} from "@webstudio-is/icons";
import { useSubscribe } from "~/shared/pubsub";
declare module "~/shared/pubsub" {
export interface PubsubMap {
showTextToolbar: TextToolbarState;
hideTextToolbar: void;
formatTextToolbar: "bold" | "italic" | "link";
formatTextToolbar: "bold" | "italic" | "link" | "span";
}
}
@@ -55,7 +60,7 @@ const getPlacement = ({
return { top, left, marginBottom, marginTop, transform, visibility };
};
type Value = "bold" | "italic" | "link";
type Value = "bold" | "italic" | "link" | "span";
const onClickPreventDefault: MouseEventHandler<HTMLDivElement> = (event) => {
event.preventDefault();
@@ -80,6 +85,9 @@ const Toolbar = ({ css, rootRef, state, onToggle }: ToolbarProps) => {
if (state.isLink) {
value.push("link");
}
if (state.isSpan) {
value.push("span");
}
return (
<ToggleGroup.Root
ref={rootRef}
@@ -96,6 +104,9 @@ const Toolbar = ({ css, rootRef, state, onToggle }: ToolbarProps) => {
if (state.isLink !== newValues.includes("link")) {
onToggle("link");
}
if (state.isSpan !== newValues.includes("span")) {
onToggle("span");
}
}}
onClick={onClickPreventDefault}
css={{
@@ -115,6 +126,9 @@ const Toolbar = ({ css, rootRef, state, onToggle }: ToolbarProps) => {
<ToggleGroup.Item value="link">
<Link2Icon />
</ToggleGroup.Item>
<ToggleGroup.Item value="span">
<BrushIcon />
</ToggleGroup.Item>
</ToggleGroup.Root>
);
};
@@ -63,6 +63,7 @@ export type TextToolbarState = {
isBold: boolean;
isItalic: boolean;
isLink: boolean;
isSpan: boolean;
};
const textToolbarState = createValueContainer<null | TextToolbarState>();
export const useTextToolbarState = () => useValue(textToolbarState);
@@ -3,26 +3,43 @@
// We have to use getBoundingClientRect instead.
// @todo optimize for the case when many consumers need to measure the same element
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useScrollState } from "./use-scroll-state";
export type UseMeasureRef<MeasuredElement extends HTMLElement = HTMLElement> = (
element: MeasuredElement | null
) => void;
export type UseMeasureResult<
MeasuredElement extends HTMLElement = HTMLElement
> = [UseMeasureRef<MeasuredElement>, DOMRect | undefined];
export const useRectState = () => {
const [rect, setRectBase] = useState<DOMRect>();
const setRect = useCallback(
(nextRect: DOMRect | undefined) =>
setRectBase((currentRect) => {
if (
currentRect === undefined ||
nextRect === undefined ||
currentRect.x !== nextRect.x ||
currentRect.y !== nextRect.y ||
currentRect.width !== nextRect.width ||
currentRect.height !== nextRect.height
) {
return nextRect;
}
return currentRect;
}),
[]
);
return [rect, setRect] as const;
};
export const useMeasure = <
MeasuredElement extends HTMLElement = HTMLElement
>(): UseMeasureResult<MeasuredElement> => {
const [element, setElement] = useState<MeasuredElement | null>(null);
const [rect, setRect] = useState<DOMRect>();
export const useMeasure = (
element: HTMLElement | undefined
): { canObserve: boolean; rect: DOMRect | undefined } => {
const [rect, setRect] = useRectState();
const [isInline, setIsInline] = useState(false);
const handleChange = useCallback(() => {
if (element === null) return;
setRect(element.getBoundingClientRect());
}, [element]);
setRect(element && element.getBoundingClientRect());
setIsInline(
element ? window.getComputedStyle(element).display === "inline" : false
);
}, [element, setRect]);
useScrollState({
onScrollEnd: handleChange,
@@ -40,22 +57,19 @@ export const useMeasure = <
}
}, [element, handleChange]);
const observer = useMemo(() => {
if (typeof window === "undefined") return;
return new window.ResizeObserver(handleChange);
}, [handleChange]);
useEffect(() => {
if (observer) {
if (element === null) observer.disconnect();
else observer.observe(element);
if (element) {
const observer = new window.ResizeObserver(handleChange);
observer.observe(element);
return () => observer.disconnect();
}
return () => {
observer?.disconnect();
};
}, [element, observer]);
}, [element, handleChange]);
useEffect(handleChange, [handleChange]);
return [setElement, rect];
return {
// ResizeObserver does not work for inline elements
canObserve: isInline === false,
rect,
};
};
+2 -1
View File
@@ -4,6 +4,7 @@ import { createPubsub } from "@webstudio-is/react-sdk";
// eslint-disable-next-line @typescript-eslint/no-empty-interface
export interface PubsubMap {}
export const { publish, usePublish, useSubscribe } = createPubsub<PubsubMap>();
export const { publish, usePublish, useSubscribe, useSubscribeAll } =
createPubsub<PubsubMap>();
export type Publish = typeof publish;
export type UsePublish = typeof usePublish;
@@ -4,6 +4,7 @@ export { default as TextBlock } from "./text-block.ws";
export { default as Heading } from "./heading.ws";
export { default as Paragraph } from "./paragraph.ws";
export { default as Link } from "./link.ws";
export { default as Span } from "./span.ws";
export { default as Bold } from "./bold.ws";
export { default as Italic } from "./italic.ws";
export { default as Button } from "./button.ws";
@@ -8,5 +8,6 @@ export { default as Input } from "./input.stories";
export { default as Italic } from "./italic.stories";
export { default as Link } from "./link.stories";
export { default as Paragraph } from "./paragraph.stories";
export { default as Span } from "./span.stories";
export { default as TextBlock } from "./text-block.stories";
export { default as Image } from "./image.stories";
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,18 @@
import type { ComponentStory, ComponentMeta } from "@storybook/react";
import { Span as SpanPrimitive } from "./span";
import argTypes from "./span.props.json";
export default {
title: "Components/Span",
component: SpanPrimitive,
argTypes,
} as ComponentMeta<typeof SpanPrimitive>;
const Template: ComponentStory<typeof SpanPrimitive> = (args) => (
<SpanPrimitive {...args} />
);
export const Span = Template.bind({});
Span.args = {
children: "some span text",
};
@@ -0,0 +1,10 @@
import { forwardRef, type ElementRef, type ComponentProps } from "react";
const defaultTag = "span";
export const Span = forwardRef<
ElementRef<typeof defaultTag>,
ComponentProps<typeof defaultTag>
>((props, ref) => <span {...props} ref={ref} />);
Span.displayName = "Span";
@@ -0,0 +1,15 @@
import { BrushIcon } from "@webstudio-is/icons";
import type { WsComponentMeta } from "./component-type";
import { Span } from "./span";
const meta: WsComponentMeta<typeof Span> = {
Icon: BrushIcon,
Component: Span,
canAcceptChildren: false,
isContentEditable: false,
label: "Styled Text",
isInlineOnly: true,
isListed: false,
};
export default meta;
+14
View File
@@ -66,5 +66,19 @@ export const createPubsub = <PublishMap>() => {
};
}, [type, onAction]);
},
useSubscribeAll(
onAction: <Type extends keyof PublishMap>(
type: Type,
payload: PublishMap[Type]
) => void
) {
useEffect(() => {
emitter.on("*", onAction);
return () => {
emitter.off("*", onAction);
};
}, [onAction]);
},
};
};