Peng Xiao 3 سال پیش
والد
کامیت
759e724a0f

+ 2 - 2
tldraw/apps/tldraw-logseq/src/components/icons/LineIcon.tsx

@@ -12,8 +12,8 @@ export function ArrowIcon() {
       <path
         d="M3.64645 11.3536C3.45118 11.1583 3.45118 10.8417 3.64645 10.6465L10.2929 4L6 4C5.72386 4 5.5 3.77614 5.5 3.5C5.5 3.22386 5.72386 3 6 3L11.5 3C11.6326 3 11.7598 3.05268 11.8536 3.14645C11.9473 3.24022 12 3.36739 12 3.5L12 9.00001C12 9.27615 11.7761 9.50001 11.5 9.50001C11.2239 9.50001 11 9.27615 11 9.00001V4.70711L4.35355 11.3536C4.15829 11.5488 3.84171 11.5488 3.64645 11.3536Z"
         fill="currentColor"
-        fill-rule="evenodd"
-        clip-rule="evenodd"
+        fillRule="evenodd"
+        clipRule="evenodd"
       ></path>
     </svg>
   )

+ 56 - 17
tldraw/apps/tldraw-logseq/src/lib/shapes/LineShape.tsx

@@ -1,16 +1,22 @@
 /* eslint-disable @typescript-eslint/no-explicit-any */
 import { Decoration, TLLineShape, TLLineShapeProps } from '@tldraw/core'
-import { SVGContainer, TLComponentProps } from '@tldraw/react'
+import { HTMLContainer, SVGContainer, TLComponentProps } from '@tldraw/react'
 import { observer } from 'mobx-react-lite'
 import * as React from 'react'
 import { getArrowPath } from './arrow/arrowHelpers'
 import { Arrow } from './arrow/Arrow'
 import { CustomStyleProps, withClampedStyles } from './style-props'
+import { TextLabel } from './text/TextLabel'
+import { getTextLabelSize } from './text/getTextSize'
+import Vec from '@tldraw/vec'
 
 interface LineShapeProps extends CustomStyleProps, TLLineShapeProps {
   type: 'line'
+  label: string
 }
 
+const font = '28px / 1 "Source Code Pro"'
+
 export class LineShape extends TLLineShape<LineShapeProps> {
   static id = 'line'
 
@@ -30,11 +36,12 @@ export class LineShape extends TLLineShape<LineShapeProps> {
     decorations: {
       end: Decoration.Arrow,
     },
+    label: '',
   }
 
   hideSelection = true
 
-  ReactComponent = observer(({ events, isErasing }: TLComponentProps) => {
+  ReactComponent = observer(({ events, isErasing, isEditing, onEditingEnd }: TLComponentProps) => {
     const {
       stroke,
       fill,
@@ -42,23 +49,55 @@ export class LineShape extends TLLineShape<LineShapeProps> {
       decorations,
       handles: { start, end },
       opacity,
+      label,
     } = this.props
+    const labelSize = label || isEditing ? getTextLabelSize(label, font) : [0, 0]
+    const midPoint = Vec.med(start.point, end.point)
+    const dist = Vec.dist(start.point, end.point)
+    const scale = Math.max(
+      0.5,
+      Math.min(1, Math.max(dist / (labelSize[1] + 128), dist / (labelSize[0] + 128)))
+    )
+    const bounds = this.getBounds()
+    const offset = React.useMemo(() => {
+      const offset = Vec.sub(midPoint, Vec.toFixed([bounds.width / 2, bounds.height / 2]))
+      return offset
+    }, [bounds, scale, midPoint])
+    const handleLabelChange = React.useCallback(
+      (label: string) => {
+        this.update?.({ label })
+      },
+      [label]
+    )
     return (
-      <SVGContainer {...events} opacity={isErasing ? 0.2 : opacity}>
-        <g pointerEvents="none">
-          <Arrow
-            style={{
-              stroke,
-              fill,
-              strokeWidth,
-            }}
-            start={start.point}
-            end={end.point}
-            decorationStart={decorations?.start}
-            decorationEnd={decorations?.end}
-          />
-        </g>
-      </SVGContainer>
+      <div {...events} style={{ width: '100%', height: '100%', overflow: 'hidden' }}>
+        <TextLabel
+          font={font}
+          text={label}
+          color={stroke}
+          offsetX={offset[0]}
+          offsetY={offset[1]}
+          scale={scale}
+          isEditing={isEditing}
+          onChange={handleLabelChange}
+          onBlur={onEditingEnd}
+        />
+        <SVGContainer opacity={isErasing ? 0.2 : opacity}>
+          <g pointerEvents="none">
+            <Arrow
+              style={{
+                stroke,
+                fill,
+                strokeWidth,
+              }}
+              start={start.point}
+              end={end.point}
+              decorationStart={decorations?.start}
+              decorationEnd={decorations?.end}
+            />
+          </g>
+        </SVGContainer>
+      </div>
     )
   })
 

+ 36 - 0
tldraw/apps/tldraw-logseq/src/lib/shapes/text/LabelMask.tsx

@@ -0,0 +1,36 @@
+import type { TLBounds } from '@tldraw/core'
+import * as React from 'react'
+
+interface WithLabelMaskProps {
+  id: string
+  bounds: TLBounds
+  labelSize: number[]
+  offset?: number[]
+  scale?: number
+}
+
+export function LabelMask({ id, bounds, labelSize, offset, scale = 1 }: WithLabelMaskProps) {
+  return (
+    <defs>
+      <mask id={id + '_clip'}>
+        <rect
+          x={-100}
+          y={-100}
+          width={bounds.width + 200}
+          height={bounds.height + 200}
+          fill="white"
+        />
+        <rect
+          x={bounds.width / 2 - (labelSize[0] / 2) * scale + (offset?.[0] || 0)}
+          y={bounds.height / 2 - (labelSize[1] / 2) * scale + (offset?.[1] || 0)}
+          width={labelSize[0] * scale}
+          height={labelSize[1] * scale}
+          rx={4 * scale}
+          ry={4 * scale}
+          fill="black"
+          opacity={Math.max(scale, 0.8)}
+        />
+      </mask>
+    </defs>
+  )
+}

+ 171 - 0
tldraw/apps/tldraw-logseq/src/lib/shapes/text/TextAreaUtils.ts

@@ -0,0 +1,171 @@
+// Adapted (mostly copied) the work of https://github.com/fregante
+// Copyright (c) Federico Brigante <[email protected]> (bfred.it)
+
+type ReplacerCallback = (substring: string, ...args: unknown[]) => string
+
+const INDENT = '  '
+
+export class TextAreaUtils {
+  static insertTextFirefox(field: HTMLTextAreaElement | HTMLInputElement, text: string): void {
+    // Found on https://www.everythingfrontend.com/posts/insert-text-into-textarea-at-cursor-position.html 🎈
+    field.setRangeText(
+      text,
+      field.selectionStart || 0,
+      field.selectionEnd || 0,
+      'end' // Without this, the cursor is either at the beginning or `text` remains selected
+    )
+
+    field.dispatchEvent(
+      new InputEvent('input', {
+        data: text,
+        inputType: 'insertText',
+        isComposing: false, // TODO: fix @types/jsdom, this shouldn't be required
+      })
+    )
+  }
+
+  /** Inserts `text` at the cursor’s position, replacing any selection, with **undo** support and by firing the `input` event. */
+  static insert(field: HTMLTextAreaElement | HTMLInputElement, text: string): void {
+    const document = field.ownerDocument
+    const initialFocus = document.activeElement
+    if (initialFocus !== field) {
+      field.focus()
+    }
+
+    if (!document.execCommand('insertText', false, text)) {
+      TextAreaUtils.insertTextFirefox(field, text)
+    }
+
+    if (initialFocus === document.body) {
+      field.blur()
+    } else if (initialFocus instanceof HTMLElement && initialFocus !== field) {
+      initialFocus.focus()
+    }
+  }
+
+  /** Replaces the entire content, equivalent to `field.value = text` but with **undo** support and by firing the `input` event. */
+  static set(field: HTMLTextAreaElement | HTMLInputElement, text: string): void {
+    field.select()
+    TextAreaUtils.insert(field, text)
+  }
+
+  /** Get the selected text in a field or an empty string if nothing is selected. */
+  static getSelection(field: HTMLTextAreaElement | HTMLInputElement): string {
+    const { selectionStart, selectionEnd } = field
+    return field.value.slice(
+      selectionStart ? selectionStart : undefined,
+      selectionEnd ? selectionEnd : undefined
+    )
+  }
+
+  /** Adds the `wrappingText` before and after field’s selection (or cursor). If `endWrappingText` is provided, it will be used instead of `wrappingText` at on the right. */
+  static wrapSelection(
+    field: HTMLTextAreaElement | HTMLInputElement,
+    wrap: string,
+    wrapEnd?: string
+  ): void {
+    const { selectionStart, selectionEnd } = field
+    const selection = TextAreaUtils.getSelection(field)
+    TextAreaUtils.insert(field, wrap + selection + (wrapEnd ?? wrap))
+
+    // Restore the selection around the previously-selected text
+    field.selectionStart = (selectionStart || 0) + wrap.length
+    field.selectionEnd = (selectionEnd || 0) + wrap.length
+  }
+
+  /** Finds and replaces strings and regex in the field’s value, like `field.value = field.value.replace()` but better */
+  static replace(
+    field: HTMLTextAreaElement | HTMLInputElement,
+    searchValue: string | RegExp,
+    replacer: string | ReplacerCallback
+  ): void {
+    /** Remembers how much each match offset should be adjusted */
+    let drift = 0
+
+    field.value.replace(searchValue, (...args): string => {
+      // Select current match to replace it later
+      const matchStart = drift + (args[args.length - 2] as number)
+      const matchLength = args[0].length
+      field.selectionStart = matchStart
+      field.selectionEnd = matchStart + matchLength
+
+      const replacement = typeof replacer === 'string' ? replacer : replacer(...args)
+      TextAreaUtils.insert(field, replacement)
+
+      // Select replacement. Without this, the cursor would be after the replacement
+      field.selectionStart = matchStart
+      drift += replacement.length - matchLength
+      return replacement
+    })
+  }
+
+  static findLineEnd(value: string, currentEnd: number): number {
+    // Go to the beginning of the last line
+    const lastLineStart = value.lastIndexOf('\n', currentEnd - 1) + 1
+
+    // There's nothing to unindent after the last cursor, so leave it as is
+    if (value.charAt(lastLineStart) !== '\t') {
+      return currentEnd
+    }
+
+    return lastLineStart + 1 // Include the first character, which will be a tab
+  }
+
+  static indent(element: HTMLTextAreaElement): void {
+    const { selectionStart, selectionEnd, value } = element
+    const selectedContrast = value.slice(selectionStart, selectionEnd)
+    // The first line should be indented, even if it starts with `\n`
+    // The last line should only be indented if includes any character after `\n`
+    const lineBreakCount = /\n/g.exec(selectedContrast)?.length
+
+    if (lineBreakCount && lineBreakCount > 0) {
+      // Select full first line to replace everything at once
+      const firstLineStart = value.lastIndexOf('\n', selectionStart - 1) + 1
+
+      const newSelection = element.value.slice(firstLineStart, selectionEnd - 1)
+      const indentedText = newSelection.replace(
+        /^|\n/g, // Match all line starts
+        `$&${INDENT}`
+      )
+      const replacementsCount = indentedText.length - newSelection.length
+
+      // Replace newSelection with indentedText
+      element.setSelectionRange(firstLineStart, selectionEnd - 1)
+      TextAreaUtils.insert(element, indentedText)
+
+      // Restore selection position, including the indentation
+      element.setSelectionRange(selectionStart + 1, selectionEnd + replacementsCount)
+    } else {
+      TextAreaUtils.insert(element, INDENT)
+    }
+  }
+
+  // The first line should always be unindented
+  // The last line should only be unindented if the selection includes any characters after `\n`
+  static unindent(element: HTMLTextAreaElement): void {
+    const { selectionStart, selectionEnd, value } = element
+
+    // Select the whole first line because it might contain \t
+    const firstLineStart = value.lastIndexOf('\n', selectionStart - 1) + 1
+    const minimumSelectionEnd = TextAreaUtils.findLineEnd(value, selectionEnd)
+
+    const newSelection = element.value.slice(firstLineStart, minimumSelectionEnd)
+    const indentedText = newSelection.replace(/(^|\n)(\t| {1,2})/g, '$1')
+    const replacementsCount = newSelection.length - indentedText.length
+
+    // Replace newSelection with indentedText
+    element.setSelectionRange(firstLineStart, minimumSelectionEnd)
+    TextAreaUtils.insert(element, indentedText)
+
+    // Restore selection position, including the indentation
+    const firstLineIndentation = /\t| {1,2}/.exec(value.slice(firstLineStart, selectionStart))
+
+    const difference = firstLineIndentation ? firstLineIndentation[0].length : 0
+
+    const newSelectionStart = selectionStart - difference
+    element.setSelectionRange(
+      selectionStart - difference,
+      Math.max(newSelectionStart, selectionEnd - replacementsCount)
+    )
+  }
+}

+ 185 - 0
tldraw/apps/tldraw-logseq/src/lib/shapes/text/TextLabel.tsx

@@ -0,0 +1,185 @@
+import { TextUtils } from '@tldraw/core'
+import * as React from 'react'
+import { LETTER_SPACING } from './constants'
+import { getTextLabelSize } from './getTextSize'
+import { TextAreaUtils } from './TextAreaUtils'
+
+const stopPropagation = (e: KeyboardEvent | React.SyntheticEvent<any, Event>) => e.stopPropagation()
+
+export interface TextLabelProps {
+  font: string
+  text: string
+  color: string
+  onBlur?: () => void
+  onChange: (text: string) => void
+  offsetY?: number
+  offsetX?: number
+  scale?: number
+  isEditing?: boolean
+}
+
+export const TextLabel = React.memo(function TextLabel({
+  font,
+  text,
+  color,
+  offsetX = 0,
+  offsetY = 0,
+  scale = 1,
+  isEditing = false,
+  onBlur,
+  onChange,
+}: TextLabelProps) {
+  const rInput = React.useRef<HTMLTextAreaElement>(null)
+  const rIsMounted = React.useRef(false)
+
+  const handleChange = React.useCallback(
+    (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+      onChange(TextUtils.normalizeText(e.currentTarget.value))
+    },
+    [onChange]
+  )
+  const handleKeyDown = React.useCallback(
+    (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+      if (e.key === 'Escape') return
+
+      if (e.key === 'Tab' && text.length === 0) {
+        e.preventDefault()
+        return
+      }
+
+      if (!(e.key === 'Meta' || e.metaKey)) {
+        e.stopPropagation()
+      } else if (e.key === 'z' && e.metaKey) {
+        if (e.shiftKey) {
+          document.execCommand('redo', false)
+        } else {
+          document.execCommand('undo', false)
+        }
+        e.stopPropagation()
+        e.preventDefault()
+        return
+      }
+
+      if (e.key === 'Tab') {
+        e.preventDefault()
+        if (e.shiftKey) {
+          TextAreaUtils.unindent(e.currentTarget)
+        } else {
+          TextAreaUtils.indent(e.currentTarget)
+        }
+
+        onChange?.(TextUtils.normalizeText(e.currentTarget.value))
+      }
+    },
+    [onChange]
+  )
+
+  const handleBlur = React.useCallback(
+    (e: React.FocusEvent<HTMLTextAreaElement>) => {
+      e.currentTarget.setSelectionRange(0, 0)
+      onBlur?.()
+    },
+    [onBlur]
+  )
+
+  const handleFocus = React.useCallback(
+    (e: React.FocusEvent<HTMLTextAreaElement>) => {
+      if (!isEditing) return
+      if (!rIsMounted.current) return
+
+      if (document.activeElement === e.currentTarget) {
+        e.currentTarget.select()
+      }
+    },
+    [isEditing]
+  )
+
+  const handlePointerDown = React.useCallback(
+    e => {
+      if (isEditing) {
+        e.stopPropagation()
+      }
+    },
+    [isEditing]
+  )
+
+  React.useEffect(() => {
+    if (isEditing) {
+      requestAnimationFrame(() => {
+        rIsMounted.current = true
+        const elm = rInput.current
+        if (elm) {
+          elm.focus()
+          elm.select()
+        }
+      })
+    } else {
+      onBlur?.()
+    }
+  }, [isEditing, onBlur])
+
+  const rInnerWrapper = React.useRef<HTMLDivElement>(null)
+
+  React.useLayoutEffect(() => {
+    const elm = rInnerWrapper.current
+    if (!elm) return
+    const size = getTextLabelSize(text, font)
+    elm.style.transform = `scale(${scale}, ${scale}) translate(${offsetX}px, ${offsetY}px)`
+    elm.style.width = size[0] + 1 + 'px'
+    elm.style.height = size[1] + 1 + 'px'
+  }, [text, font, offsetY, offsetX, scale])
+
+  return (
+    <div className="text-label-wrapper">
+      <div
+        className="text-label-inner-wrapper"
+        ref={rInnerWrapper}
+        style={{
+          font,
+          color,
+          letterSpacing: LETTER_SPACING,
+          pointerEvents: text ? 'all' : 'none',
+          userSelect: isEditing ? 'text' : 'none',
+        }}
+      >
+        {isEditing ? (
+          <textarea
+            ref={rInput}
+            style={{
+              font,
+              color,
+            }}
+            className="text-label-textarea"
+            name="text"
+            tabIndex={-1}
+            autoComplete="false"
+            autoCapitalize="false"
+            autoCorrect="false"
+            autoSave="false"
+            autoFocus
+            placeholder=""
+            spellCheck="true"
+            wrap="off"
+            dir="auto"
+            datatype="wysiwyg"
+            defaultValue={text}
+            color={color}
+            onFocus={handleFocus}
+            onChange={handleChange}
+            onKeyDown={handleKeyDown}
+            onBlur={handleBlur}
+            onPointerDown={handlePointerDown}
+            onContextMenu={stopPropagation}
+            onCopy={stopPropagation}
+            onPaste={stopPropagation}
+            onCut={stopPropagation}
+          />
+        ) : (
+          text
+        )}
+        &#8203;
+      </div>
+    </div>
+  )
+})
+

+ 2 - 0
tldraw/apps/tldraw-logseq/src/lib/shapes/text/constants.ts

@@ -0,0 +1,2 @@
+export const LETTER_SPACING = '-0.03em'
+export const GHOSTED_OPACITY = 0.3

+ 77 - 0
tldraw/apps/tldraw-logseq/src/lib/shapes/text/getTextSize.ts

@@ -0,0 +1,77 @@
+import { LETTER_SPACING } from "./constants"
+
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+let melm: any
+
+function getMeasurementDiv() {
+  // A div used for measurement
+  document.getElementById('__textLabelMeasure')?.remove()
+
+  const pre = document.createElement('pre')
+  pre.id = '__textLabelMeasure'
+
+  Object.assign(pre.style, {
+    whiteSpace: 'pre',
+    width: 'auto',
+    border: '1px solid transparent',
+    padding: '4px',
+    margin: '0px',
+    letterSpacing: LETTER_SPACING,
+    opacity: '0',
+    position: 'absolute',
+    top: '-500px',
+    left: '0px',
+    zIndex: '9999',
+    pointerEvents: 'none',
+    userSelect: 'none',
+    alignmentBaseline: 'mathematical',
+    dominantBaseline: 'mathematical',
+  })
+
+  pre.tabIndex = -1
+
+  document.body.appendChild(pre)
+  return pre
+}
+
+if (typeof window !== 'undefined') {
+  melm = getMeasurementDiv()
+}
+
+let prevText = ''
+let prevFont = ''
+let prevSize = [0, 0]
+
+export function clearPrevSize() {
+  prevText = ''
+}
+
+export function getTextLabelSize(text: string, font: string) {
+  if (!text) {
+    return [16, 32]
+  }
+
+  if (!melm) {
+    // We're in SSR
+    return [10, 10]
+  }
+
+  if (!melm.parent) document.body.appendChild(melm)
+
+  if (text === prevText && font === prevFont) {
+    return prevSize
+  }
+
+  prevText = text
+  prevFont = font
+
+  melm.textContent = text
+  melm.style.font = font
+
+  // In tests, offsetWidth and offsetHeight will be 0
+  const width = melm.offsetWidth || 1
+  const height = melm.offsetHeight || 1
+
+  prevSize = [width, height]
+  return prevSize
+}

+ 62 - 0
tldraw/apps/tldraw-logseq/src/styles.css

@@ -291,3 +291,65 @@
   stroke-linecap: round;
   stroke-linejoin: round;
 }
+
+.logseq-tldraw .text-label-wrapper {
+  position: absolute;
+  top: 0px;
+  left: 0px;
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  pointer-events: none;
+  user-select: none;
+}
+
+.logseq-tldraw .text-label-inner-wrapper {
+  position: absolute;
+  padding: 4px;
+  z-index: 1;
+  min-height: 1px;
+  min-width: 1px;
+  line-height: 1;
+  outline: 0px;
+  font-weight: 500;
+  text-align: center;
+  backface-visibility: hidden;
+  user-select: none;
+  white-space: pre-wrap;
+  overflow-wrap: break-word;
+}
+
+.logseq-tldraw .text-label-textarea {
+  position: absolute;
+  top: 0px;
+  left: 0px;
+  z-index: 1;
+  width: 100%;
+  height: 100%;
+  border: none;
+  padding: 4px;
+  resize: none;
+  text-align: inherit;
+  min-height: inherit;
+  min-width: inherit;
+  line-height: inherit;
+  letter-spacing: inherit;
+  outline: 0px;
+  font-weight: inherit;
+  overflow: hidden;
+  backface-visibility: hidden;
+  display: inline-block;
+  pointer-events: all;
+  background: var(--colors-boundsBg);
+  user-select: text;
+  -webkit-font-smoothing: subpixel-antialiased;
+  white-space: pre-wrap;
+  overflow-wrap: break-word;
+}
+
+.logseq-tldraw .text-label-textarea:focus {
+  outline: none;
+  border: none;
+}