mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-11-07 20:44:05 +09:00
Merge branch 'main' into migrate-tokens
This commit is contained in:
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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
+17
-3
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user