) {
const { resizeMode } = this.options
switch (resizeMode) {
case 'none': {
return undefined
}
case 'scale': {
return resizeScaled(shape, info)
}
default: {
throw exhaustiveSwitchError(resizeMode)
}
}
}
override getText(shape: TLNoteShape) {
return renderPlaintextFromRichText(this.editor, shape.props.richText)
}
override getReferencedUserIds(shape: TLNoteShape) {
return shape.props.textLastEditedBy ? [shape.props.textLastEditedBy] : []
}
override getFontFaces(shape: TLNoteShape) {
const fonts = isEmptyRichText(shape.props.richText)
? []
: getFontsFromRichText(this.editor, shape.props.richText, {
family: `tldraw_${shape.props.font}`,
weight: 'normal',
style: 'normal',
})
const themeFaces = getThemeFontFaces(this.editor.getCurrentTheme(), shape.props.font)
const textFaces = themeFaces ? [...themeFaces, ...fonts] : fonts
// The attribution line renders in the default sans face regardless of the note's font.
// Theme faces come first so an attributed note keeps its custom font (export included).
if (shape.props.textLastEditedBy && !isEmptyRichText(shape.props.richText)) {
return [...textFaces, DefaultFontFaces.tldraw_sans.normal.normal]
}
return textFaces.length ? textFaces : EMPTY_ARRAY
}
component(shape: TLNoteShape) {
const { id, type, props } = shape
const { scale, richText, fontSizeAdjustment, textLastEditedBy } = props
const handleKeyDown = useNoteKeydownHandler(id)
const rotation = useValue(
'shape rotation',
() => this.editor.getShapePageTransform(id)?.rotation() ?? 0,
[this.editor]
)
const colorMode = useColorMode()
const dv = getDisplayValues(this, shape, colorMode)
const nw = dv.noteWidth * scale
const nh = getNoteHeight(shape, dv.noteHeight)
// Shadows are hidden when zoomed out far enough; the cheap borderBottom takes over.
const hideShadows = useEfficientZoomThreshold(0.25 / scale)
const isSelected = shape.id === this.editor.getOnlySelectedShapeId()
const isReadyForEditing = useIsReadyForEditing(this.editor, shape.id)
const isEmpty = isEmptyRichText(richText)
const NoteShapeAttribution = this.options.AttributionComponent
const attribution = useValue(
'attribution',
() => {
if (!textLastEditedBy || isEmpty) return null
const name = this.editor.getAttributionDisplayName(textLastEditedBy)
if (!name) return null
return { short: name.split(' ')[0], full: name }
},
[textLastEditedBy, isEmpty, this.editor]
)
return (
<>
{attribution && NoteShapeAttribution && (
)}
{(isSelected || isReadyForEditing || !isEmpty) && (
)}
{'url' in shape.props && shape.props.url && }
>
)
}
override getIndicatorPath(shape: TLNoteShape): Path2D {
const { scale } = shape.props
const dv = getDisplayValues(this, shape)
const path = new Path2D()
path.rect(0, 0, dv.noteWidth * scale, getNoteHeight(shape, dv.noteHeight))
return path
}
override toSvg(shape: TLNoteShape, ctx: SvgExportContext) {
const dv = getDisplayValues(this, shape, ctx.colorMode)
const bounds = new Box(0, 0, dv.noteWidth, dv.noteHeight + shape.props.growY)
const filterId = `note-shadow-${shape.id.replace(/:/g, '_')}` as SafeId
ctx.addExportDef({
key: filterId,
getElement: () => (
),
})
const { textLastEditedBy } = shape.props
const attributionFullName =
textLastEditedBy && !isEmptyRichText(shape.props.richText)
? (this.editor.getAttributionDisplayName(textLastEditedBy) ?? null)
: null
const attributionFirstName = attributionFullName?.split(' ')[0] ?? null
const NoteShapeAttribution = this.options.AttributionComponent
return (
<>
{ctx.isDarkMode ? null : (
)}
{attributionFirstName && attributionFullName && NoteShapeAttribution && (
)}
>
)
}
override onBeforeCreate(next: TLNoteShape) {
return this.getNoteSizeAdjustments(next)
}
override onBeforeDuplicate(
_source: TLNoteShape,
duplicate: TLNoteShape
): TLNoteShape | undefined {
// Attribution follows the last person to produce the note's text. Duplicating (or pasting)
// a note with text is a new act of authorship by the current user, so re-stamp the copy to
// them rather than carrying over the original author's identity. Empty notes have no
// attribution to begin with, so leave them alone.
if (isEmptyRichText(duplicate.props.richText)) return
return {
...duplicate,
props: { ...duplicate.props, textLastEditedBy: this.editor.getAttributionUserId() },
}
}
override onBeforeUpdate(prev: TLNoteShape, next: TLNoteShape) {
const richTextChanged = !isEqual(prev.props.richText, next.props.richText)
if (
!richTextChanged &&
prev.props.font === next.props.font &&
prev.props.size === next.props.size
) {
return
}
let shape = next
if (richTextChanged) {
if (isEmptyRichText(next.props.richText)) {
shape = {
...shape,
props: { ...shape.props, textLastEditedBy: null },
}
} else {
shape = {
...shape,
props: { ...shape.props, textLastEditedBy: this.editor.getAttributionUserId() },
}
}
}
return this.getNoteSizeAdjustments(shape) ?? (richTextChanged ? shape : undefined)
}
override getInterpolatedProps(
startShape: TLNoteShape,
endShape: TLNoteShape,
t: number
): TLNoteShapeProps {
return {
...(t > 0.5 ? endShape.props : startShape.props),
scale: lerp(startShape.props.scale, endShape.props.scale, t),
}
}
/**
* Get the growY and fontSizeAdjustment for a shape.
*/
private getNoteSizeAdjustments(shape: TLNoteShape) {
const dv = getDisplayValues(this, shape)
const { labelHeight, fontSizeAdjustment } = this.getLabelSize(shape)
// When the label height is more than the height of the shape, we add extra height to it
const growY = Math.max(0, labelHeight - dv.noteHeight)
if (growY !== shape.props.growY || fontSizeAdjustment !== shape.props.fontSizeAdjustment) {
return {
...shape,
props: {
...shape.props,
growY,
fontSizeAdjustment,
},
}
}
return undefined
}
private _labelSizesForNoteCache = new WeakCache<
TLShape,
{ labelHeight: number; labelWidth: number; fontSizeAdjustment: number }
>()
/**
* Get the cached label size for the shape.
*/
private getLabelSize(shape: TLNoteShape) {
return this._labelSizesForNoteCache.get(shape, () => this.measureNoteLabelSize(shape))
}
/**
* Expensively measure the label size for a note shape.
*/
private measureNoteLabelSize(shape: TLNoteShape) {
const dv = getDisplayValues(this, shape)
const { richText } = shape.props
if (isEmptyRichText(richText)) {
const minHeight =
resolveLineHeightPx(dv.labelFontSize, dv.labelLineHeight) + dv.labelPadding * 2
return { labelHeight: minHeight, labelWidth: 100, fontSizeAdjustment: 1 }
}
const unadjustedFontSize = dv.labelFontSize
let fontSizeAdjustment = unadjustedFontSize
let iterations = 0
let labelHeight = dv.noteHeight
let labelWidth = dv.noteWidth
// N.B. For some note shapes with text like 'hjhjhjhjhjhjhjhj', you'll run into
// some text measurement fuzziness where the browser swears there's no overflow (scrollWidth === width)
// but really there is when you enable overflow-wrap again. This helps account for that little bit
// of give.
const FUZZ = 1
// We slightly make the font smaller if the text is too big for the note, width-wise.
do {
fontSizeAdjustment = Math.min(unadjustedFontSize, unadjustedFontSize - iterations)
const html = renderHtmlFromRichTextForMeasurement(this.editor, richText)
const nextTextSize = this.editor.textMeasure.measureHtml(html, {
...TEXT_PROPS,
lineHeight: dv.labelLineHeight,
fontFamily: dv.labelFontFamily,
fontSize: fontSizeAdjustment,
maxWidth: dv.noteWidth - dv.labelPadding * 2 - FUZZ,
disableOverflowWrapBreaking: true,
measureScrollWidth: true,
})
labelHeight = nextTextSize.h + dv.labelPadding * 2
labelWidth = nextTextSize.w + dv.labelPadding * 2
if (fontSizeAdjustment <= 14) {
// Too small, just rely now on CSS `overflow-wrap: break-word`
// We need to recalculate the text measurement here with break-word enabled.
const html = renderHtmlFromRichTextForMeasurement(this.editor, richText)
const nextTextSizeWithOverflowBreak = this.editor.textMeasure.measureHtml(html, {
...TEXT_PROPS,
lineHeight: dv.labelLineHeight,
fontFamily: dv.labelFontFamily,
fontSize: fontSizeAdjustment,
maxWidth: dv.noteWidth - dv.labelPadding * 2 - FUZZ,
})
labelHeight = nextTextSizeWithOverflowBreak.h + dv.labelPadding * 2
labelWidth = nextTextSizeWithOverflowBreak.w + dv.labelPadding * 2
break
}
if (nextTextSize.scrollWidth.toFixed(0) === nextTextSize.w.toFixed(0)) {
break
}
} while (iterations++ < 50)
return {
labelHeight: labelHeight,
labelWidth: labelWidth,
fontSizeAdjustment:
fontSizeAdjustment === unadjustedFontSize ? 1 : fontSizeAdjustment / unadjustedFontSize,
}
}
}
function useNoteKeydownHandler(id: TLShapeId) {
const editor = useEditor()
// Try to get the translation context, but fallback to ltr if it doesn't exist
const translation = useContext(TranslationsContext)
return useCallback(
(e: KeyboardEvent) => {
const shape = editor.getShape(id)
if (!shape) return
const isTab = e.key === 'Tab'
const isCmdEnter = (e.metaKey || e.ctrlKey) && e.key === 'Enter'
if (isTab && isEditingRichTextList(editor)) {
// In a list, let the rich text editor indent the item instead of
// creating a new note. Prevent default so Tab doesn't move focus out
// of the editor when the item can't be indented (e.g. the first item).
e.preventDefault()
return
}
if (isTab || isCmdEnter) {
e.preventDefault()
const pageTransform = editor.getShapePageTransform(id)
const pageRotation = pageTransform.rotation()
// Based on the inputs, calculate the offset to the next note
// tab controls x axis (shift inverts direction set by RTL)
// cmd enter is the y axis (shift inverts direction)
const isRTL = !!(
translation?.dir === 'rtl' ||
// todo: can we check a partial of the text, so that we don't have to render the whole thing?
isRightToLeftLanguage(renderPlaintextFromRichText(editor, shape.props.richText))
)
const noteUtil = editor.getShapeUtil(shape) as NoteShapeUtil
const dv = getDisplayValues(noteUtil, shape)
const noteOffset = isTab
? dv.noteWidth + editor.options.adjacentShapeMargin
: dv.noteHeight +
editor.options.adjacentShapeMargin +
// If we're growing down, we need to account for the current shape's growY
(isCmdEnter && !e.shiftKey ? shape.props.growY : 0)
const offsetLength = noteOffset * shape.props.scale
const adjacentCenter = new Vec(
isTab ? (e.shiftKey != isRTL ? -1 : 1) : 0,
isCmdEnter ? (e.shiftKey ? -1 : 1) : 0
)
.mul(offsetLength)
.add(new Vec(dv.noteWidth / 2, dv.noteHeight / 2).mul(shape.props.scale))
.rot(pageRotation)
.add(pageTransform.point())
const newNote = getNoteShapeForAdjacentPosition(editor, {
shape,
center: adjacentCenter,
pageRotation,
noteWidth: dv.noteWidth,
noteHeight: dv.noteHeight,
})
if (newNote) {
startEditingAdjacentNote(editor, newNote)
}
}
},
[id, editor, translation?.dir]
)
}
function getNoteHeight(shape: TLNoteShape, noteHeight: number) {
return (noteHeight + shape.props.growY) * shape.props.scale
}
function getNoteShadow(id: string, rotation: number, scale: number) {
const random = rng(id) // seeded based on id
const lift = Math.abs(random()) + 0.5 // 0 to 1.5
const oy = Math.cos(rotation)
const a = 5 * scale
const b = 4 * scale
const c = 6 * scale
const d = 7 * scale
// Clamped so shadow never goes above the note at small scales (e.g. dynamic size mode at high zoom)
return `0px ${Math.max(0, a - lift)}px ${a}px -${a}px rgba(15, 23, 31, .6),
0px ${(b + lift * d) * Math.max(0, oy)}px ${c + lift * d}px -${b + lift * c}px rgba(15, 23, 31, ${(0.3 + lift * 0.1).toFixed(2)}),
0px ${48 * scale}px ${10 * scale}px -${10 * scale}px inset rgba(15, 23, 44, ${((0.022 + random() * 0.005) * ((1 + oy) / 2)).toFixed(2)})`
}