Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { SimpleModal } from 'tapestry-core-client/src/components/lib/modal/index
import styles from './styles.module.css'
import { Icon } from 'tapestry-core-client/src/components/lib/icon/index'
import { Text } from 'tapestry-core-client/src/components/lib/text/index'
import { hasThumbnail } from 'tapestry-core/src/utils'
import { FilePicker } from 'tapestry-core-client/src/components/lib/file-picker/index'
import { CSSProperties, useState } from 'react'
import { DropArea } from 'tapestry-core-client/src/components/lib/drop-area'
Expand All @@ -17,6 +16,7 @@ import { itemSizes, uploadAsset } from '../../../../model/data/utils'
import { LoadingSpinner } from 'tapestry-core-client/src/components/lib/loading-spinner/index'
import { compressImage, getImageSize } from '../../../../lib/media'
import { Size } from 'tapestry-core/src/data-format/schemas/common'
import { pick } from 'lodash-es'
import { aspectRatio } from 'tapestry-core/src/lib/geometry'

interface NoCustomThumbnailOptionProps {
Expand Down Expand Up @@ -55,25 +55,14 @@ export function ChangeThumbnailButton({ onClick, className }: ChangeThumbnailBut
}

interface ItemThumbnailProps {
item: ItemDto
thumbnail?: string | null
className?: string
useAutoGenerated: boolean
customBlobUrl: string | null
style?: CSSProperties
}

function ItemThumbnail({
item,
className,
useAutoGenerated,
customBlobUrl,
style,
}: ItemThumbnailProps) {
const autoThumbnail = hasThumbnail(item) ? item.thumbnail.source : null

const src = useAutoGenerated
? autoThumbnail
: (customBlobUrl ?? item.customThumbnail ?? autoThumbnail)
function ItemThumbnail({ thumbnail, className, customBlobUrl, style }: ItemThumbnailProps) {
const src = customBlobUrl ?? thumbnail

return src ? <img src={src} className={className} style={style} alt="Thumbnail preview" /> : null
}
Expand All @@ -89,34 +78,46 @@ async function getNewItemSize(item: ItemDto, thumbnail?: File) {
}
}

async function getImageDimensions(file: File): Promise<Size> {
const objectUrl = URL.createObjectURL(file)
const img = new Image()
img.src = objectUrl
await img.decode()
URL.revokeObjectURL(objectUrl)
return pick(img, 'width', 'height')
}

interface ChangeThumbnailDialogProps {
onClose: () => unknown
item: ItemDto
}

export function ChangeThumbnailDialog({ onClose, item }: ChangeThumbnailDialogProps) {
const [useAutoGenerated, setUseAutoGenerated] = useState(false)
const [selectedFile, setSelectedFile] = useState<File>()
const [selectedFile, setSelectedFile] = useState<{ file: File; size: Size }>()
const [customThumbnailItemSize, setCustomThumbnailItemSize] = useState<Size>()
const blobUrl = useMediaSource(selectedFile ?? item.customThumbnail ?? null)
const customThumbnail = item.thumbnail?.renditions.find((r) => !r.isAutoGenerated)
const thumbnail = customThumbnail ?? item.thumbnail?.renditions.find((r) => r.isPrimary)
const blobUrl = useMediaSource(selectedFile?.file ?? customThumbnail?.source ?? null)

const onNewFile = async (file: File) => {
const newItemSize = await getNewItemSize(item, file)
setCustomThumbnailItemSize(newItemSize)
const compressed = await compressImage(file, newItemSize)
const size = await getImageDimensions(compressed)
setUseAutoGenerated(false)
setSelectedFile(compressed)
setSelectedFile({ file: compressed, size })
}

const { trigger: save, loading } = useAsyncAction(async ({ signal }) => {
const newSize = useAutoGenerated ? await getNewItemSize(item) : customThumbnailItemSize
if (useAutoGenerated) {
if (item.customThumbnail) {
if (customThumbnail) {
await resource('items').update(
{ id: item.id },
{
type: item.type,
customThumbnail: null,
thumbnail: null,
...(newSize ? { size: newSize } : {}),
},
undefined,
Expand All @@ -128,7 +129,7 @@ export function ChangeThumbnailDialog({ onClose, item }: ChangeThumbnailDialogPr
}
if (selectedFile) {
const key = await uploadAsset(
selectedFile,
selectedFile.file,
{
tapestryId: item.tapestryId,
type: 'tapestry-asset',
Expand All @@ -139,7 +140,10 @@ export function ChangeThumbnailDialog({ onClose, item }: ChangeThumbnailDialogPr
{ id: item.id },
{
type: item.type,
customThumbnail: key,
thumbnail: {
source: key,
size: selectedFile.size,
},
...(newSize ? { size: newSize } : {}),
},
undefined,
Expand All @@ -158,8 +162,7 @@ export function ChangeThumbnailDialog({ onClose, item }: ChangeThumbnailDialogPr
<div className={styles.root}>
<div className={styles.previewContainer}>
<ItemThumbnail
item={item}
useAutoGenerated={useAutoGenerated}
thumbnail={thumbnail?.source}
customBlobUrl={blobUrl}
className={styles.previewImage}
style={{ aspectRatio: aspectRatio(customThumbnailItemSize ?? item.size) }}
Expand All @@ -182,9 +185,7 @@ export function ChangeThumbnailDialog({ onClose, item }: ChangeThumbnailDialogPr
variant="clear"
onClick={() => setUseAutoGenerated(true)}
>
{hasThumbnail(item) ? (
<img className={styles.fitImage} src={item.thumbnail.source} />
) : null}
{thumbnail ? <img className={styles.fitImage} src={thumbnail.source} /> : null}
<div className={styles.overlayContainer}>
<NoCustomThumbnailOption item={item} />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,8 @@ import { ReactNode } from 'react'
import { useObservable } from 'tapestry-core-client/src/components/lib/hooks/use-observable'
import { TapestryItem as BaseTapestryItem } from 'tapestry-core-client/src/components/tapestry/items/tapestry-item'
import { THEMES } from 'tapestry-core-client/src/theme/themes'
import {
computeRestrictedScale,
positionAtViewport,
} from 'tapestry-core-client/src/view-model/utils'
import { ORIGIN, Rectangle, scaleSize, Size } from 'tapestry-core/src/lib/geometry'
import { computeRestrictedScale } from 'tapestry-core-client/src/view-model/utils'
import { Size } from 'tapestry-core/src/lib/geometry'
import { idMapToArray } from 'tapestry-core/src/utils'
import { useDispatch, useTapestryData } from '../../../../pages/tapestry/tapestry-providers'
import {
Expand All @@ -34,20 +31,12 @@ export function TapestryItem({ id, children, halo }: TapestryItemProps) {
interactiveElement,
interactionMode,
theme: themeName,
viewport,
} = useTapestryData(['interactiveElement', 'interactionMode', 'theme', 'viewport'])
const dispatch = useDispatch()
const isEditMode = interactionMode === 'edit'

const isContentInteractive = id === interactiveElement?.modelId

const viewportRect = new Rectangle(
positionAtViewport(viewport, ORIGIN),
scaleSize(viewport.size, 1 / viewport.transform.scale),
)

const isVisible = viewportRect.intersects(new Rectangle(dto))

// @ts-expect-error TS wants us to check for a media item
const item = useObservable(itemUpload).find((i) => i.objectUrl === dto.source)

Expand All @@ -59,7 +48,6 @@ export function TapestryItem({ id, children, halo }: TapestryItemProps) {
root: styles.root,
hitArea: styles.hitArea,
}}
style={!isVisible ? { display: 'none' } : undefined}
title={
<>
<span>{dto.title}</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { SimpleMenuItem } from 'tapestry-core-client/src/components/lib/toolbar'
import {
ALLOWED_ORIGINS,
getPlaybackInterval,
WebFrameProps,
WebpageItemViewer,
WebpageItemViewerApi,
} from 'tapestry-core-client/src/components/tapestry/items/webpage/viewer'
Expand All @@ -28,12 +27,16 @@ import { PlayableShareMenu, shareMenu } from '../../item-toolbar/share-menu'
import { useItemToolbar } from '../../item-toolbar/use-item-toolbar'
import { TapestryItem } from '../tapestry-item'
import styles from './styles.module.css'
import {
WebFrame,
WebFrameSwitchProps,
} from 'tapestry-core-client/src/components/tapestry/items/webpage/web-frame'

const checkedSources = new Map<string, boolean>()

const PLAYABLE_WEBPAGE_TYPES: WebpageType[] = ['iaAudio', 'iaVideo', 'vimeo', 'youtube']

function Webpage({ src, onLoad, ...props }: WebFrameProps) {
function Webpage({ src, onLoad, ...props }: WebFrameSwitchProps) {
const onLoadRef = usePropRef(onLoad)
const interactionMode = useTapestryData('interactionMode')
const checkCanFrame = interactionMode === 'edit'
Expand Down Expand Up @@ -70,7 +73,7 @@ function Webpage({ src, onLoad, ...props }: WebFrameProps) {
)

return canFrame ? (
<iframe src={src} onLoad={onLoad} {...props} />
<WebFrame src={src} onLoad={onLoad} {...props} />
) : canFrame === false ? (
<div className={styles.error}>
<Icon icon="sentiment_very_dissatisfied" />
Expand Down
5 changes: 2 additions & 3 deletions client/src/components/tapestry-elements/rel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,12 @@ export const Rel = memo(({ id }: RelProps) => {
const {
interactiveElement,
items,
viewport,
theme: themeName,
} = useTapestryData(['interactiveElement', 'items', 'theme', 'viewport'])
} = useTapestryData(['interactiveElement', 'items', 'theme'])
const theme = THEMES[themeName]
const isActive = rel.dto.id === interactiveElement?.modelId

const curve = computeRelCurvePoints(rel, viewport, items)
const curve = computeRelCurvePoints(rel, items)
const bounds = getBounds(rel.dto, items)

return (
Expand Down
17 changes: 14 additions & 3 deletions client/src/model/data/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
MediaItemSource,
} from '../../lib/media'
import { resource } from '../../services/rest-resources'
import { isFunction } from 'lodash-es'
import { isFunction, omit } from 'lodash-es'
import mime from 'mime'
import axios, { AxiosProgressEvent } from 'axios'
import { arrayToIdMap, fileExtension, isMediaItem } from 'tapestry-core/src/utils'
Expand Down Expand Up @@ -160,6 +160,7 @@ export function fromTapestryDto(
userAccess: UserAccess,
commentThreads: CommentThreadsDto,
presentationSteps: PresentationStepDto[],
deoptimize: boolean,
): EditableTapestryViewModel {
const presentationStepViewModels = presentationSteps.map((dto) => ({ dto }))

Expand All @@ -175,6 +176,7 @@ export function fromTapestryDto(
)
const editableTapestryViewModel: EditableTapestryViewModel = {
...baseViewModel,
disableOptimizations: deoptimize,
items: Object.fromEntries(tapestry.items?.map((item) => [item.id, { dto: item }]) ?? []),
rels: Object.fromEntries(tapestry.rels?.map((rel) => [rel.id, { dto: rel }]) ?? []),
groups: Object.fromEntries(tapestry.groups?.map((group) => [group.id, { dto: group }]) ?? []),
Expand Down Expand Up @@ -315,8 +317,17 @@ export async function createMediaItem<T extends MediaItemType>(
// 2) Implement some sort of ref counting to S3 resource. This can be done only in the context of a tapestry
// or maybe even globally. This way we can save on some storage space, but it will introduce additional
// complexity
export function duplicateItem<T extends ItemDto>(item: T): ItemCreateDto {
return structuredClone(item)
export function duplicateItem<I extends ItemDto>(item: I): ItemCreateDto {
// We clone items without thumbnails for now, since we don't have a mechanism to create thumbnails during item
// creation. This is only a problem if the item has a custom thumbnail - it will be cloned without it. In this
// case, however, the cloning procedure would be more complicated since the thumbnail asset itself needs to be
// copied first and attached to the cloned item afterwards.
const createDto = omit(item, ['id', 'createdAt', 'updatedAt', 'tapestry', 'thumbnail'])
// XXX: I can't convince TypeScript that the type is correct at this point. In fact, it would be more appropriate
// if the return type is some kind of mapped type that returns the correct CreateDto that corresponds to the given
// item type I. However, I tried this approach and it also didn't work. I am force-casting it for now and hopefully
// we'll figure it out at some point.
return structuredClone(createDto) as unknown as ItemCreateDto
}

export async function getMediaType(source: MediaItemSource): Promise<string | null> {
Expand Down
7 changes: 5 additions & 2 deletions client/src/pages/tapestry/tapestry-loader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { pick } from 'lodash-es'
import { useEffect, useMemo } from 'react'
import { Navigate, useNavigate } from 'react-router'
import { Navigate, useNavigate, useSearchParams } from 'react-router'
import { useAsync } from 'tapestry-core-client/src/components/lib/hooks/use-async'
import { useObservable } from 'tapestry-core-client/src/components/lib/hooks/use-observable'
import { TapestryConfig, TapestryConfigContext } from 'tapestry-core-client/src/components/tapestry'
Expand Down Expand Up @@ -43,6 +43,7 @@ function getErrorMessage(error: unknown) {
return 'Tapestry not found'
}
}
console.error(error)
return 'Error loading tapestry'
}

Expand All @@ -68,6 +69,7 @@ export interface TapestryLoaderProps {
export function TapestryLoader({ id, mode }: TapestryLoaderProps) {
const navigate = useNavigate()
const tapestryViewPath = useTapestryPath('view')
const [searchParams] = useSearchParams()
const { user } = useObservable(auth)

const config = useMemo(
Expand Down Expand Up @@ -118,7 +120,8 @@ export function TapestryLoader({ id, mode }: TapestryLoaderProps) {
void navigate(tapestryViewPath)
}

const dataSync = new TapestryDataSync(id, canEdit ? mode : 'view', userAccess)
const deopt = !!searchParams.get('deopt')
const dataSync = new TapestryDataSync(id, canEdit ? mode : 'view', userAccess, deopt)
onCleanup(() => dataSync.dispose())

await dataSync.init(signal)
Expand Down
2 changes: 1 addition & 1 deletion client/src/pages/tapestry/tapestry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function Tapestry() {
const store = useTapestryStore()
const tapestryDataSyncCommandsRef = usePropRef(useTapestryDataSyncCommands())
useStageInit(sceneRef, {
gestureDectorOptions: { scrollGesture: 'pan', dragToPan: store.get('pointerMode') === 'pan' },
gestureDetectorOptions: { scrollGesture: 'pan', dragToPan: store.get('pointerMode') === 'pan' },
createPixiApps: async () => {
const overlay = new Color(THEMES[store.get('theme')].color('overlay'))
return [
Expand Down
2 changes: 2 additions & 0 deletions client/src/pages/tapestry/view-model/tapestry-data-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ export class TapestryDataSync {
private tapestryId: string,
private initialMode: InteractionMode,
private userAccess: UserAccess,
private deoptimize: boolean,
) {
this.socketManager = new SocketManager(tapestryId)
// The conversion is made because TS forbids more generic add/remove event listener functions
Expand Down Expand Up @@ -145,6 +146,7 @@ export class TapestryDataSync {
this.userAccess,
commentThreads,
idMapToArray(this.tapestryRepo.value.presentationSteps),
this.deoptimize,
)
this._store = new Store(tapestryViewModel, [
{
Expand Down
Loading