Skip to content

Commit

Permalink
Allow the copy/pasting of listfield items (#403)
Browse files Browse the repository at this point in the history
* Allow the copy/pasting of listfield items

* Remove icon default exports

* Cleanup debug logs

---------

Co-authored-by: Ben Merckx <[email protected]>
  • Loading branch information
dmerckx and benmerckx authored Jan 17, 2025
1 parent be6205e commit 0821760
Show file tree
Hide file tree
Showing 7 changed files with 125 additions and 18 deletions.
6 changes: 6 additions & 0 deletions src/core/shape/ListShape.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface ListMutator<Row> {
push(row: Omit<Row, '_id' | '_index'>, insertAt?: number): void
remove(id: string): void
move(oldIndex: number, newIndex: number): void
read(id: string): Row | undefined
}

export class ListShape<Row extends ListRow>
Expand Down Expand Up @@ -208,6 +209,11 @@ export class ListShape<Row extends ListRow>
const index = generateKeyBetween(a, b)
const row = record.get(from[ListRow.id])
row.set(ListRow.index, index)
},
read: (id: string): Row | undefined => {
const record = parent.get(key)
const rows: Array<Row> = this.fromY(record) as any
return rows.find(row => row._id === id)
}
}
return res
Expand Down
4 changes: 4 additions & 0 deletions src/dashboard/view/Create.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@
position: relative;
}

&.is-paste {
border: 1px solid currentColor;
}

&:hover {
color: var(--alinea-button-foreground);
background: var(--alinea-button-background);
Expand Down
4 changes: 3 additions & 1 deletion src/dashboard/view/Create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export namespace Create {

export type Props = {
icon?: ComponentType
mod?: 'paste'
}

export function Link({
Expand All @@ -44,13 +45,14 @@ export namespace Create {
export function Button({
children,
icon: Icon,
mod,
...props
}: HTMLAttributes<HTMLButtonElement> & Props) {
return (
<button
type="button"
{...props}
className={styles.button.mergeProps(props)()}
className={styles.button.mergeProps(props)(mod)}
>
<HStack center gap={8}>
{Icon ? (
Expand Down
88 changes: 72 additions & 16 deletions src/field/list/ListField.view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@ import {Create} from 'alinea/dashboard/view/Create'
import {IconButton} from 'alinea/dashboard/view/IconButton'
import {InputLabel} from 'alinea/dashboard/view/InputLabel'
import {Icon, TextLabel} from 'alinea/ui'
import {IcBaselineContentCopy} from 'alinea/ui/icons/IcBaselineContentCopy'
import {IcBaselineContentPasteGo} from 'alinea/ui/icons/IcBaselineContentPasteGo'
import {IcOutlineList} from 'alinea/ui/icons/IcOutlineList'
import IcRoundAdd from 'alinea/ui/icons/IcRoundAdd'
import {IcRoundAdd} from 'alinea/ui/icons/IcRoundAdd'
import {IcRoundClose} from 'alinea/ui/icons/IcRoundClose'
import {IcRoundDragHandle} from 'alinea/ui/icons/IcRoundDragHandle'
import {IcRoundKeyboardArrowDown} from 'alinea/ui/icons/IcRoundKeyboardArrowDown'
import {IcRoundKeyboardArrowUp} from 'alinea/ui/icons/IcRoundKeyboardArrowUp'
import {Sink} from 'alinea/ui/Sink'
import {useAtom} from 'jotai'
import {atomWithStorage} from 'jotai/utils'
import {
CSSProperties,
HTMLAttributes,
Expand All @@ -54,14 +58,19 @@ import css from './ListField.module.scss'

const styles = styler(css)

const copyAtom = atomWithStorage<ListRow | undefined>(
`@alinea/copypaste`,
undefined
)

function animateLayoutChanges(args: FirstArgument<AnimateLayoutChanges>) {
const {isSorting, wasSorting} = args
if (isSorting || wasSorting) return defaultAnimateLayoutChanges(args)
return true
}

function ListInputRowSortable(props: ListInputRowProps) {
const {onCreate} = props
const {onCreate, onPaste} = props
const {attributes, listeners, setNodeRef, transform, transition, isDragging} =
useSortable({
animateLayoutChanges,
Expand All @@ -80,6 +89,7 @@ function ListInputRowSortable(props: ListInputRowProps) {
{...attributes}
isDragging={isDragging}
onCreate={onCreate}
onPaste={onPaste}
/>
)
}
Expand All @@ -92,13 +102,15 @@ type ListInputRowProps = PropsWithChildren<
readOnly?: boolean
onMove?: (direction: 1 | -1) => void
onDelete?: () => void
onCopyBlock?: () => void
handle?: DraggableSyntheticListeners
// React ts types force our hand here since it's a generic component,
// and forwardRef does not forward generics.
// There's probably an issue for this on DefinitelyTyped.
rootRef?: Ref<HTMLDivElement>
isDragOverlay?: boolean
onCreate?: (type: string) => void
onPasteBlock?: (data: ListRow) => void
firstRow?: boolean
} & HTMLAttributes<HTMLDivElement>
>
Expand All @@ -108,12 +120,14 @@ function ListInputRow({
schema,
onMove,
onDelete,
onCopyBlock,
handle,
rootRef,
isDragging,
isDragOverlay,
readOnly,
onCreate,
onPasteBlock,
firstRow,
...rest
}: ListInputRowProps) {
Expand Down Expand Up @@ -141,6 +155,10 @@ function ListInputRow({
onCreate!(type)
setShowInsert(false)
}}
onPaste={(data: ListRow) => {
if (onPasteBlock) onPasteBlock(data)
setShowInsert(false)
}}
/>
)}
<Sink.Header>
Expand All @@ -154,19 +172,29 @@ function ListInputRow({
<Sink.Title>
<TextLabel label={Type.label(type)} />
</Sink.Title>
{!readOnly && (
<Sink.Options>
<Sink.Options>
{onCopyBlock !== undefined && (
<IconButton
icon={IcRoundKeyboardArrowUp}
onClick={() => onMove?.(-1)}
/>
<IconButton
icon={IcRoundKeyboardArrowDown}
onClick={() => onMove?.(1)}
icon={IcBaselineContentCopy}
onClick={() => {
onCopyBlock()
}}
/>
<IconButton icon={IcRoundClose} onClick={onDelete} />
</Sink.Options>
)}
)}
{!readOnly && (
<>
<IconButton
icon={IcRoundKeyboardArrowUp}
onClick={() => onMove?.(-1)}
/>
<IconButton
icon={IcRoundKeyboardArrowDown}
onClick={() => onMove?.(1)}
/>
<IconButton icon={IcRoundClose} onClick={onDelete} />
</>
)}
</Sink.Options>
</Sink.Header>
<Sink.Content>
<InputForm type={type} />
Expand All @@ -179,15 +207,20 @@ interface ListCreateRowProps {
schema: Schema
readOnly?: boolean
inline?: boolean
onCreate: (type: string) => void
onCreate: (type: string, data?: ListRow) => void
onPaste: (data: ListRow) => void
}

function ListCreateRow({
schema,
readOnly,
inline,
onCreate
onCreate,
onPaste
}: ListCreateRowProps) {
const [pasted] = useAtom(copyAtom)
const canPaste =
pasted && entries(schema).some(([key]) => key === pasted._type)
return (
<div className={styles.create({inline})}>
<Create.Root disabled={readOnly}>
Expand All @@ -202,6 +235,15 @@ function ListCreateRow({
</Create.Button>
)
})}
{canPaste && (
<Create.Button
icon={IcBaselineContentPasteGo}
onClick={() => onPaste(pasted)}
mod="paste"
>
<TextLabel label="Paste block" />
</Create.Button>
)}
</Create.Root>
</div>
)
Expand Down Expand Up @@ -238,6 +280,7 @@ export function ListInput({field}: ListInputProps) {
const {schema, readOnly} = options
const rows: Array<ListRow> = value as any
const ids = rows.map(row => row._id)
const [, setPasted] = useAtom(copyAtom)
const [dragging, setDragging] = useState<ListRow | null>(null)
const sensors = useSensors(
useSensor(PointerSensor),
Expand Down Expand Up @@ -285,6 +328,10 @@ export function ListInput({field}: ListInputProps) {
row={row}
schema={schema}
readOnly={readOnly}
onCopyBlock={() => {
const data = mutator.read(row._id)
setPasted(data)
}}
onMove={direction => {
if (readOnly) return
mutator.move(i, i + direction)
Expand All @@ -297,6 +344,11 @@ export function ListInput({field}: ListInputProps) {
if (readOnly) return
mutator.push({_type: type} as any, i)
}}
onPasteBlock={(data: ListRow) => {
if (readOnly) return
const {_id, _index, ...rest} = data
mutator.push(rest)
}}
firstRow={i === 0}
/>
</FormRow>
Expand All @@ -305,7 +357,11 @@ export function ListInput({field}: ListInputProps) {
<ListCreateRow
schema={schema}
readOnly={readOnly}
onCreate={(type: string) => {
onPaste={(data: ListRow) => {
const {_id, _index, ...rest} = data
mutator.push(rest)
}}
onCreate={(type: string, data?: ListRow) => {
if (readOnly) return
mutator.push({_type: type} as any)
}}
Expand Down
18 changes: 18 additions & 0 deletions src/ui/icons/IcBaselineContentCopy.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import {SVGProps} from 'react'

export function IcBaselineContentCopy(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
d="M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12V1zm3 4H8c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h11c1.1 0 2-.9 2-2V7c0-1.1-.9-2-2-2zm0 16H8V7h11v14z"
></path>
</svg>
)
}
22 changes: 22 additions & 0 deletions src/ui/icons/IcBaselineContentPasteGo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {SVGProps} from 'react'

export function IcBaselineContentPasteGo(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
{...props}
>
<path
fill="currentColor"
d="M5 5h2v3h10V5h2v6h2V5c0-1.1-.9-2-2-2h-4.18C14.4 1.84 13.3 1 12 1s-2.4.84-2.82 2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h5v-2H5V5zm7-2c.55 0 1 .45 1 1s-.45 1-1 1s-1-.45-1-1s.45-1 1-1z"
></path>
<path
fill="currentColor"
d="m18.01 13l-1.42 1.41l1.58 1.58H12v2h6.17l-1.58 1.59l1.42 1.41l3.99-4z"
></path>
</svg>
)
}
1 change: 0 additions & 1 deletion src/ui/icons/IcRoundAdd.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,3 @@ export function IcRoundAdd(props: SVGProps<SVGSVGElement>) {
</svg>
)
}
export default IcRoundAdd

0 comments on commit 0821760

Please sign in to comment.