diff --git a/packages/blocky-core/src/index.ts b/packages/blocky-core/src/index.ts index 4c76145..cb86259 100644 --- a/packages/blocky-core/src/index.ts +++ b/packages/blocky-core/src/index.ts @@ -42,6 +42,7 @@ export { type ParagraphStyle, SearchContext, darkTheme, + themeDataToCssVariables, } from "./model"; export { TextBlock } from "./block/textBlock"; export * from "./data"; diff --git a/packages/blocky-core/src/model/theme.ts b/packages/blocky-core/src/model/theme.ts index 3f38ce3..c9c4180 100644 --- a/packages/blocky-core/src/model/theme.ts +++ b/packages/blocky-core/src/model/theme.ts @@ -26,3 +26,17 @@ export const darkTheme: ThemeData = { color: "#c3c3bf", }, }; + +export function themeDataToCssVariables( + themeData?: ThemeData +): Record { + const result = Object.create(null); + + const primaryColor = themeData?.primary?.color ?? null; + result["--blocky-primary-color"] = primaryColor; + + const font = themeData?.font ?? blockyDefaultFonts; + result["--blocky-font"] = font; + + return result; +} diff --git a/packages/blocky-core/src/view/editor.ts b/packages/blocky-core/src/view/editor.ts index 54ac560..712c947 100644 --- a/packages/blocky-core/src/view/editor.ts +++ b/packages/blocky-core/src/view/editor.ts @@ -14,14 +14,16 @@ import { BehaviorSubject, timer, filter, + Observable, } from "rxjs"; -import { debounce, isUndefined, isString, isNumber } from "lodash-es"; +import { debounce, isUndefined, isNumber } from "lodash-es"; import { DocRenderer, RenderFlag, RenderOption } from "@pkg/view/renderer"; import { EditorState, NodeTraverser, SearchContext, blockyDefaultFonts, + themeDataToCssVariables, } from "@pkg/model"; import { type CursorStateUpdateEvent, @@ -306,6 +308,10 @@ export class Editor { }); } + get themeData$(): Observable { + return this.#themeData.asObservable(); + } + get themeData(): ThemeData | undefined { return this.#themeData.value; } @@ -519,6 +525,14 @@ export class Editor { ); } + private handleThemeChanged(themeData: ThemeData | undefined) { + const cssVariables = themeDataToCssVariables(themeData); + + for (const [key, value] of Object.entries(cssVariables)) { + this.#container.style.setProperty(key, value); + } + } + render(option: RenderOption, done?: AfterFn) { try { const newDom = this.#renderer.render(option, this.#renderedDom); @@ -531,28 +545,7 @@ export class Editor { this.#themeData .pipe(takeUntil(this.dispose$)) - .subscribe((themeData) => { - if (isString(themeData?.primary?.color)) { - this.#container.style.setProperty( - "--blocky-primary-color", - themeData!.primary!.color - ); - } else { - this.#container.style.setProperty("--blocky-primary-color", null); - } - - if (isString(themeData?.font)) { - this.#container.style.setProperty( - "--blocky-font", - themeData!.font! - ); - } else { - this.#container.style.setProperty( - "--blocky-font", - blockyDefaultFonts - ); - } - }); + .subscribe(this.handleThemeChanged.bind(this)); fromEvent(this.#container, "mousemove") .pipe( diff --git a/packages/blocky-core/src/view/spannerDelegate.spec.ts b/packages/blocky-core/src/view/spannerDelegate.spec.ts new file mode 100644 index 0000000..647bbdc --- /dev/null +++ b/packages/blocky-core/src/view/spannerDelegate.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import { SpannerDelegate, SpannerInstance } from "./spannerDelegate"; +import { EditorController } from "./controller"; +import { BlockDataElement } from ".."; + +describe("SpannerDelegate", () => { + it("focusedNode", () => { + const editorController = new EditorController("user"); + + const spannerInstance: SpannerInstance = { + onFocusedNodeChanged: () => {}, + dispose() {}, + }; + + const mount = document.createElement("div"); + + const delegate = new SpannerDelegate(editorController, () => { + return spannerInstance; + }); + delegate.mount(mount); + + const focusedNode1 = new BlockDataElement("Text", "id-1"); + const focusedNode2 = new BlockDataElement("Text", "id-2"); + + const focusedNodeChangedSpy = vi.spyOn( + spannerInstance, + "onFocusedNodeChanged" + ); + const disposeSpy = vi.spyOn(spannerInstance, "dispose"); + + delegate.focusedNode = focusedNode1; + + expect(focusedNodeChangedSpy).toHaveBeenCalledTimes(1); + + delegate.focusedNode = focusedNode2; + + expect(focusedNodeChangedSpy).toHaveBeenCalledTimes(2); + + delegate.focusedNode = focusedNode2; + expect(focusedNodeChangedSpy).toHaveBeenCalledTimes(2); // shoud not increase + + delegate.dispose(); + expect(disposeSpy).toHaveBeenCalledOnce(); + }); +}); diff --git a/packages/blocky-core/src/view/spannerDelegate.ts b/packages/blocky-core/src/view/spannerDelegate.ts index dbb88c4..a971ae4 100644 --- a/packages/blocky-core/src/view/spannerDelegate.ts +++ b/packages/blocky-core/src/view/spannerDelegate.ts @@ -23,6 +23,9 @@ export class SpannerDelegate extends UIDelegate { } set focusedNode(v: BlockDataElement | undefined) { + if (this.#focusedNode === v) { + return; + } this.#focusedNode = v; this.#instance?.onFocusedNodeChanged?.(v); } @@ -62,8 +65,16 @@ export class SpannerDelegate extends UIDelegate { } } + #cachedX = 0; + #cachedY = 0; + setPosition(x: number, y: number) { + if (this.#cachedX === x && this.#cachedY === y) { + return; + } this.container.style.top = y + "px"; this.container.style.left = x + "px"; + this.#cachedX = x; + this.#cachedY = y; } } diff --git a/packages/blocky-react/package.json b/packages/blocky-react/package.json index cabfb1a..01e4aed 100644 --- a/packages/blocky-react/package.json +++ b/packages/blocky-react/package.json @@ -11,7 +11,7 @@ "homepage": "https://github.com/vincentdchan/blocky-editor", "keywords": [ "editor", - "preact" + "react" ], "author": "Vincent Chan ", "license": "MIT", @@ -27,11 +27,13 @@ "blocky-core": "workspace:^3.6.0" }, "dependencies": { + "@emotion/css": "^11.11.2", "@emotion/react": "^11.11.1", "blocky-common": "workspace:3.6.0", "lodash-es": "*", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-icons": "^4.12.0", "rxjs": "^7.8.1" } } diff --git a/packages/blocky-react/src/components/dropdown/dropdown.tsx b/packages/blocky-react/src/components/dropdown/dropdown.tsx index 4faee3b..101868b 100644 --- a/packages/blocky-react/src/components/dropdown/dropdown.tsx +++ b/packages/blocky-react/src/components/dropdown/dropdown.tsx @@ -1,6 +1,8 @@ -import { RefObject, useEffect, useRef, useState } from "react"; +import { RefObject, useContext, useEffect, useRef, useState } from "react"; import ReactDOM, { createPortal } from "react-dom"; import Mask from "@pkg/components/mask"; +import { ReactTheme } from "@pkg/reactTheme"; +import { themeDataToCssVariables } from "blocky-core"; export interface DropdownProps { show?: boolean; @@ -16,7 +18,7 @@ interface Coord { } const zero: Coord = { x: 0, y: 0 }; -const margin = 16; +const margin = 24; function fixMenuCoord( coord: Coord, @@ -40,6 +42,7 @@ function Dropdown(props: DropdownProps) { const [menuCoord, setMenuCoord] = useState(zero); const [shown, setShown] = useState(false); const contentRef = useRef(null); + const themeData = useContext(ReactTheme); useEffect(() => { if (show) { @@ -85,6 +88,7 @@ function Dropdown(props: DropdownProps) { position: "fixed", left: `${menuCoord.x}px`, top: `${menuCoord.y}px`, + ...themeDataToCssVariables(themeData), }} > {overlay()} diff --git a/packages/blocky-react/src/components/menu/menu.tsx b/packages/blocky-react/src/components/menu/menu.tsx index 782a84e..2da55bc 100644 --- a/packages/blocky-react/src/components/menu/menu.tsx +++ b/packages/blocky-react/src/components/menu/menu.tsx @@ -37,23 +37,38 @@ export function Menu(props: MenuProps) { } export interface MenuItemProps { + icon?: React.ReactNode; style?: React.CSSProperties; onClick?: () => void; children?: any; } const menuItemStyle = css({ - width: "240px", + display: "flex", + flexDirection: "row", + alignItems: "center", + width: 240, padding: "8px 12px", - fontSize: "14px", - color: "rgb(72, 72, 72)", + fontSize: 12, + color: "var(--blocky-primary-color)", "&:hover": { backgroundColor: "rgba(0, 0, 0, 0.1)", }, }); +const menuIconStyle = css({ + marginRight: 8, + display: "flex", + justifyContent: "center", + alignItems: "center", + svg: { + width: 16, + height: 16, + }, +}); + export function MenuItem(props: MenuItemProps) { - const { style, onClick, children } = props; + const { icon, style, onClick, children } = props; return (
+
{icon}
{children}
); diff --git a/packages/blocky-react/src/components/tooltip.tsx b/packages/blocky-react/src/components/tooltip.tsx new file mode 100644 index 0000000..5c664e2 --- /dev/null +++ b/packages/blocky-react/src/components/tooltip.tsx @@ -0,0 +1,211 @@ +import { RefObject, useContext, useEffect } from "react"; +import { Subject, fromEvent, switchMap, takeUntil, timer, map } from "rxjs"; +import { css } from "@emotion/css"; +import { ThemeData, themeDataToCssVariables } from "blocky-core"; +import { ReactTheme } from ".."; + +const tooltipStyle = css({ + fontFamily: "var(--blocky-font)", + position: "fixed", + backgroundColor: "rgb(15, 15, 15)", + color: "rgb(231, 231, 231)", + pointerEvents: "none", + padding: "4px 6px", + borderRadius: 4, + fontSize: 12, + fontWeight: 500, +}); + +interface Size { + width: number; + height: number; +} + +const PADDING = 4; +const PRESERVE_PADDING = 24; +const tooltipTimeout = 600; + +function getPopupCoord( + direction: Direction, + rect: DOMRect, + popupSize: Size +): [number, number] { + let x = rect.x; + let y = rect.y; + const { width: popupWidth, height: popupHeight } = popupSize; + const tooltipWidth = popupWidth | 0; + switch (direction) { + case "top": { + x += (rect.width / 2 - tooltipWidth / 2) | 0; + y = (y - popupHeight - PADDING) | 0; + break; + } + + case "bottom": { + x += (rect.width / 2 - tooltipWidth / 2) | 0; + y += rect.height; + y += PADDING; + break; + } + + case "topLeftAligned": { + y -= popupHeight; + y -= PADDING; + break; + } + + case "bottomLeftAligned": { + y += rect.height; + y += PADDING; + break; + } + + case "bottomRightAligned": { + x += rect.width; + x -= tooltipWidth; + y += rect.height; + y += PADDING; + break; + } + + case "right": { + x += (rect.width + PADDING) | 0; + break; + } + + default: { + } + } + + const { innerWidth } = window; + if (x + tooltipWidth >= innerWidth) { + x = innerWidth - PRESERVE_PADDING - tooltipWidth; + } + + return [x, y]; +} + +export type Direction = + | "top" + | "topLeftAligned" + | "bottom" + | "bottomLeftAligned" + | "bottomRightAligned" + | "right"; + +export interface UseTooltipOptions { + direction?: Direction; + content: string; + anchorElement: RefObject; + delay?: number; +} + +function assignStylesToContainer( + container: HTMLElement, + themeData?: ThemeData +): HTMLElement { + const cssVars = themeDataToCssVariables(themeData); + + for (const [key, value] of Object.entries(cssVars)) { + container.style.setProperty(key, value as string); + } + + return container; +} + +function getContainer(themeData?: ThemeData) { + let container = document.getElementById("blocky-tooltip"); + if (container) { + return assignStylesToContainer(container, themeData); + } + container = document.createElement("div"); + container.id = "blocky-tooltip"; + document.body.appendChild(container); + return assignStylesToContainer(container, themeData); +} + +export function useTooltip(options: UseTooltipOptions) { + const { content, anchorElement, direction, delay = tooltipTimeout } = options; + const themeData = useContext(ReactTheme); + useEffect(() => { + let tooltipElement: HTMLElement | undefined; + let isHover = false; + const handleMouseEnter = () => { + const element = tooltipElement; + if (!element) { + return; + } + if (!anchorElement.current) { + return; + } + if (!isHover) { + tooltipElement?.remove(); + tooltipElement = undefined; + return; + } + const tooltipRect = element.getBoundingClientRect(); + const targetRect = anchorElement.current.getBoundingClientRect(); + const coord = getPopupCoord(direction ?? "bottom", targetRect, { + width: tooltipRect.width, + height: tooltipRect.height, + }); + + element.style.top = coord[1] + "px"; + element.style.left = coord[0] + "px"; + }; + + const handleMouseLeave = () => { + isHover = false; + if (!tooltipElement) { + return; + } + tooltipElement.remove(); + tooltipElement = undefined; + }; + + const dispose$ = new Subject(); + const refObj = anchorElement.current; + if (refObj instanceof HTMLElement) { + fromEvent(refObj, "mouseenter") + .pipe( + map((e: MouseEvent) => { + isHover = true; + return e; + }), + switchMap((e) => timer(delay).pipe(map(() => e))), + map((e: MouseEvent) => { + e.preventDefault(); + if (tooltipElement) { + tooltipElement.remove(); + } + const element = document.createElement("div"); + element.classList.add(tooltipStyle); + element.textContent = content; + element.style.top = "-1000px"; + element.style.left = "-1000px"; + + tooltipElement = element; + getContainer(themeData).appendChild(element); + return e; + }), + switchMap((e) => timer(0).pipe(map(() => e))), + takeUntil(dispose$) + ) + .subscribe(handleMouseEnter); + fromEvent(refObj, "mouseleave") + .pipe(takeUntil(dispose$)) + .subscribe(handleMouseLeave); + fromEvent(refObj, "click") + .pipe(takeUntil(dispose$)) + .subscribe(handleMouseLeave); + } else { + console.error("not an html element:", refObj); + } + + return () => { + dispose$.next(); + dispose$.complete(); + tooltipElement?.remove(); + }; + }, [content, anchorElement, direction, themeData]); +} diff --git a/packages/blocky-react/src/defaultSpannerMenu/defaultSpannerMenu.tsx b/packages/blocky-react/src/defaultSpannerMenu/defaultSpannerMenu.tsx index 5d9a93f..ac3f967 100644 --- a/packages/blocky-react/src/defaultSpannerMenu/defaultSpannerMenu.tsx +++ b/packages/blocky-react/src/defaultSpannerMenu/defaultSpannerMenu.tsx @@ -10,6 +10,16 @@ import { Menu, MenuItem, Divider } from "@pkg/components/menu"; import { ImageBlockPlugin } from "../"; import { Subject, takeUntil } from "rxjs"; import { SpannerIcon, buttonStyle } from "./style"; +import { useTooltip } from "@pkg/components/tooltip"; +import { + LuType, + LuHeading1, + LuHeading2, + LuHeading3, + LuImage, + LuCheckCircle2, + LuTrash2, +} from "react-icons/lu"; export interface SpannerProps { editorController: EditorController; @@ -91,23 +101,33 @@ function DefaultSpannerMenu(props: SpannerProps) { const renderMenu = () => { return ( - - Text - Heading1 - Heading2 - Heading3 - Checkbox - Image + + } onClick={insertText(TextType.Normal)}> + Text + + } onClick={insertText(TextType.Heading1)}> + Heading1 + + } onClick={insertText(TextType.Heading2)}> + Heading2 + + } onClick={insertText(TextType.Heading3)}> + Heading3 + + } + onClick={insertText(TextType.Checkbox)} + > + Checkbox + + } onClick={insertImage}> + Image + {showDelete && ( <> } style={{ color: "var(--danger-color)" }} onClick={deleteBlock} > @@ -119,6 +139,11 @@ function DefaultSpannerMenu(props: SpannerProps) { ); }; + useTooltip({ + anchorElement: bannerRef, + content: "Drag to move, click to open menu", + }); + return ( + `; export const buttonStyle = css({ @@ -20,5 +20,6 @@ export const buttonStyle = css({ svg: { width: "100%", height: "100%", + fill: "var(--blocky-primary-color)", }, }); diff --git a/packages/blocky-react/src/defaultToolbar/defaultToolbar.tsx b/packages/blocky-react/src/defaultToolbar/defaultToolbar.tsx index aabd38f..f7847aa 100644 --- a/packages/blocky-react/src/defaultToolbar/defaultToolbar.tsx +++ b/packages/blocky-react/src/defaultToolbar/defaultToolbar.tsx @@ -4,19 +4,24 @@ import { type EditorController, CursorState } from "blocky-core"; import Mask from "@pkg/components/mask"; import { AnchorToolbar } from "./anchorToolbar"; import { toolbarMenuButton, toolbarContainerStyle } from "./style"; +import { useTooltip } from ".."; -const ToolbarMenuItem = memo( - (props: React.HTMLAttributes) => { - const { className = "", ...restProps } = props; - return ( -