From 0e76b5fe83d48baeb7499c41b47f7936412c6c4a Mon Sep 17 00:00:00 2001 From: Eugen Ciur Date: Sat, 23 Sep 2023 06:24:33 +0200 Subject: [PATCH] WIP: reoder-pages-in-viewer (#206) --- papermerge/core/page_operations.py | 93 ++++++++++ papermerge/core/routers/pages.py | 21 +++ papermerge/core/schemas/pages.py | 13 ++ ui/src/components/commander/commander.tsx | 1 - .../viewer/action_panel/action_panel.tsx | 27 +++ .../unapplied_page_op_changes.tsx | 27 +++ ui/src/components/viewer/pages_panel/page.tsx | 12 +- .../viewer/pages_panel/pages_panel.tsx | 14 +- .../viewer/thumbnails_panel/constants.ts | 1 + .../thumbnails_panel/page_thumbnail.scss | 13 ++ .../thumbnails_panel/page_thumbnail.tsx | 160 ++++++++++++++++-- .../thumbnails_panel/thumbnails_panel.tsx | 28 ++- ui/src/components/viewer/viewer.tsx | 55 +++++- ui/src/hooks/viewer_content_height.tsx | 16 +- ui/src/requests/viewer.ts | 9 + ui/src/styles/viewer.scss | 7 +- ui/src/types.ts | 13 ++ ui/src/utils/misc.ts | 98 ++++++++++- 18 files changed, 562 insertions(+), 46 deletions(-) create mode 100644 papermerge/core/page_operations.py create mode 100644 papermerge/core/schemas/pages.py create mode 100644 ui/src/components/viewer/action_panel/action_panel.tsx create mode 100644 ui/src/components/viewer/action_panel/unapplied_page_op_changes.tsx create mode 100644 ui/src/components/viewer/thumbnails_panel/constants.ts create mode 100644 ui/src/components/viewer/thumbnails_panel/page_thumbnail.scss create mode 100644 ui/src/requests/viewer.ts diff --git a/papermerge/core/page_operations.py b/papermerge/core/page_operations.py new file mode 100644 index 000000000..83ef88594 --- /dev/null +++ b/papermerge/core/page_operations.py @@ -0,0 +1,93 @@ +import io +import os +from pathlib import Path +from typing import List + +from pikepdf import Pdf + +from papermerge.core.models import Page +from papermerge.core.schemas import DocumentVersion as PyDocVer +from papermerge.core.schemas.pages import PageAndRotOp +from papermerge.core.storage import abs_path, get_storage_instance + + +def apply_pages_op(items: List[PageAndRotOp]): + pages = Page.objects.filter( + pk__in=[item.page.id for item in items] + ) + old_version = pages.first().document_version + + doc = old_version.document + new_version = doc.version_bump( + page_count=len(items) + ) + + reorder_pdf_pages( + old_version=old_version, + new_version=new_version, + items=items + ) + + +def reorder_pdf_pages( + old_version: PyDocVer, + new_version: PyDocVer, + items: List[PageAndRotOp] +): + src = Pdf.open(abs_path(old_version.document_path.url)) + + dst = Pdf.new() + + for item in items: + page = src.pages.p(item.page.number) + dst.pages.append(page) + + dirname = os.path.dirname( + abs_path(new_version.document_path.url) + ) + os.makedirs(dirname, exist_ok=True) + dst.save(abs_path(new_version.document_path.url)) + + +def reuse_ocr_data(uuid_map) -> None: + storage_instance = get_storage_instance() + + for src_uuid, dst_uuid in uuid_map.items(): + storage_instance.copy_page( + src=Path("pages", src_uuid), + dst=Path("pages", dst_uuid) + ) + + +def reuse_text_field( + old_version: PyDocVer, + new_version: PyDocVer, + page_map: list +) -> None: + streams = collect_text_streams( + version=old_version, + # list of old_version page numbers + page_numbers=[item[1] for item in page_map] + ) + + # updates page.text fields and document_version.text field + new_version.update_text_field(streams) + + +def collect_text_streams( + version: PyDocVer, + page_numbers: list[int] +) -> list[io.StringIO]: + """ + Returns list of texts of given page numbers from specified document version + + Each page's text is wrapped as io.StringIO instance. + """ + pages_map = {page.number: page for page in version.pages.all()} + + result = [ + io.StringIO(pages_map[number].text) + for number in page_numbers + ] + + return result diff --git a/papermerge/core/routers/pages.py b/papermerge/core/routers/pages.py index b6f71cf01..5a56d8b6f 100644 --- a/papermerge/core/routers/pages.py +++ b/papermerge/core/routers/pages.py @@ -1,13 +1,17 @@ import logging import os import uuid +from typing import List from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import FileResponse from papermerge.core.constants import DEFAULT_THUMBNAIL_SIZE from papermerge.core.models import Page, User +from papermerge.core.page_operations import apply_pages_op from papermerge.core.pathlib import rel2abs, thumbnail_path +from papermerge.core.schemas.documents import DocumentVersion as PyDocVer +from papermerge.core.schemas.pages import PageAndRotOp from papermerge.core.storage import abs_path from .auth import get_current_user as current_user @@ -94,3 +98,20 @@ def get_page_jpg_url( logger.debug(f"jpeg_abs_path={jpeg_abs_path}") return JPEGFileResponse(jpeg_abs_path) + + +@router.post("/") +def apply_page_operations( + items: List[PageAndRotOp], +) -> List[PyDocVer]: + """Applies reorder, delete and/or rotate operation(s) on a set of pages. + + Creates a new document version which will contain + only the pages provided as input in given order and with + applied rotation. The deletion operation is implicit: + pages not included in input won't be added to the new document version + which from user perspective means that pages were deleted. + Order in which input pages are provided is very important because + new document version will add pages in exact same order. + """ + apply_pages_op(items) diff --git a/papermerge/core/schemas/pages.py b/papermerge/core/schemas/pages.py new file mode 100644 index 000000000..374a3b2c1 --- /dev/null +++ b/papermerge/core/schemas/pages.py @@ -0,0 +1,13 @@ +from uuid import UUID + +from pydantic import BaseModel + + +class Page(BaseModel): + id: UUID + number: int + + +class PageAndRotOp(BaseModel): + page: Page + ccw: int = 0 diff --git a/ui/src/components/commander/commander.tsx b/ui/src/components/commander/commander.tsx index 6dc2c696c..ae4911cb6 100644 --- a/ui/src/components/commander/commander.tsx +++ b/ui/src/components/commander/commander.tsx @@ -12,7 +12,6 @@ import Menu from './menu'; import { DraggingIcon } from 'components/dragging_icon'; import { is_empty } from 'utils/misc'; -import { get_node_under_cursor } from 'utils/misc'; import { fetcher } from 'utils/fetcher'; import DeleteNodesModal from 'components/modals/delete_nodes'; diff --git a/ui/src/components/viewer/action_panel/action_panel.tsx b/ui/src/components/viewer/action_panel/action_panel.tsx new file mode 100644 index 000000000..133676b4e --- /dev/null +++ b/ui/src/components/viewer/action_panel/action_panel.tsx @@ -0,0 +1,27 @@ +import { Button } from "react-bootstrap" +import UnappliedPageOpChanges from "./unapplied_page_op_changes" + +type Args = { + unapplied_page_op_changes: boolean; + onApplyPageOpChanges: () => void; +} + +export default function ActionPanel({ + unapplied_page_op_changes, + onApplyPageOpChanges +}: Args) { + return ( +
+ + + { + unapplied_page_op_changes && + } +
+ ) +} diff --git a/ui/src/components/viewer/action_panel/unapplied_page_op_changes.tsx b/ui/src/components/viewer/action_panel/unapplied_page_op_changes.tsx new file mode 100644 index 000000000..20cc6729f --- /dev/null +++ b/ui/src/components/viewer/action_panel/unapplied_page_op_changes.tsx @@ -0,0 +1,27 @@ +import { useState } from "react"; +import { Button, Spinner } from "react-bootstrap" + + +type Args = { + onClick: () => void; +} + +export default function UnappliedPageOpChanges({onClick}: Args) { + const [inProgress, setInProgress] = useState(false); + + const onLocalClick = () => { + setInProgress(true); + onClick(); + } + + return ( + + Unapplied pages operations detected + + + ); +} diff --git a/ui/src/components/viewer/pages_panel/page.tsx b/ui/src/components/viewer/pages_panel/page.tsx index 873fd72dd..5ef14e72d 100644 --- a/ui/src/components/viewer/pages_panel/page.tsx +++ b/ui/src/components/viewer/pages_panel/page.tsx @@ -1,11 +1,11 @@ import { useRef } from 'react'; import PagePlaceholder from './page_placeholder'; -import type { PageType } from "types" +import type { PageAndRotOp } from "types" import { useProtectedSVG } from "hooks/protected_image" type Args = { - page: PageType; + item: PageAndRotOp; /* if `scroll_into_view`=True -> page should be scrolled into the view. @@ -33,14 +33,14 @@ function get_page_panel_width(): number { return ret; } -export function Page({page, scroll_into_view}: Args) { +export function Page({item, scroll_into_view}: Args) { //const base64_jpg = useProtectedJpg(page.jpg_url); const pageRef = useRef(null); let {data, is_loading, error} = useProtectedSVG( - page.svg_url, - `${page.jpg_url}?size=${get_page_panel_width()}` + item.page.svg_url, + `${item.page.jpg_url}?size=${get_page_panel_width()}` ); let page_component: JSX.Element | null @@ -52,7 +52,7 @@ export function Page({page, scroll_into_view}: Args) { page_component =
{data}
- {page.number} + {item.page.number}
} diff --git a/ui/src/components/viewer/pages_panel/pages_panel.tsx b/ui/src/components/viewer/pages_panel/pages_panel.tsx index c9b6cfa01..15249b22a 100644 --- a/ui/src/components/viewer/pages_panel/pages_panel.tsx +++ b/ui/src/components/viewer/pages_panel/pages_panel.tsx @@ -1,18 +1,18 @@ import { Page } from "./page"; -import type { PageType } from "types" +import type { PageAndRotOp } from "types" type Args = { - pages: Array; + items: Array; current_page_number: number; } -export function PagesPanel({pages, current_page_number}: Args) { +export function PagesPanel({items, current_page_number}: Args) { return (
- {pages.map(page => )} + {items.map(item => )}
); } diff --git a/ui/src/components/viewer/thumbnails_panel/constants.ts b/ui/src/components/viewer/thumbnails_panel/constants.ts new file mode 100644 index 000000000..54c55ba98 --- /dev/null +++ b/ui/src/components/viewer/thumbnails_panel/constants.ts @@ -0,0 +1 @@ +export const PAGE_ID = "page_id"; diff --git a/ui/src/components/viewer/thumbnails_panel/page_thumbnail.scss b/ui/src/components/viewer/thumbnails_panel/page_thumbnail.scss new file mode 100644 index 000000000..5e5a2437f --- /dev/null +++ b/ui/src/components/viewer/thumbnails_panel/page_thumbnail.scss @@ -0,0 +1,13 @@ + +.page-thumbnail { + border-top: 3px solid #666; + border-bottom: 3px solid #666; +} + +.borderline-top { + border-top-color: blue; +} + +.borderline-bottom { + border-bottom-color: blue; +} diff --git a/ui/src/components/viewer/thumbnails_panel/page_thumbnail.tsx b/ui/src/components/viewer/thumbnails_panel/page_thumbnail.tsx index f542a3e9d..6de0bd065 100644 --- a/ui/src/components/viewer/thumbnails_panel/page_thumbnail.tsx +++ b/ui/src/components/viewer/thumbnails_panel/page_thumbnail.tsx @@ -1,26 +1,153 @@ +import { useState, useRef } from "react"; import { useProtectedJpg } from "hooks/protected_image" +import './page_thumbnail.scss'; -import ThumbnailPlaceholder from './thumbnail_placeholder'; -import type { PageType } from "types" +import ThumbnailPlaceholder from './thumbnail_placeholder'; +import { PAGE_ID } from "./constants"; +import type { + ThumbnailPageDroppedArgs, + DroppedThumbnailPosition, + PageAndRotOp +} from "types" type Args = { - page: PageType, - onClick: (page: PageType) => void; + item: PageAndRotOp, + onClick: (page: PageAndRotOp) => void; + onThumbnailPageDropped: (args: ThumbnailPageDroppedArgs) => void; } -export function PageThumbnail({page, onClick}: Args) { - if (!page.jpg_url) { +const BORDERLINE_TOP = 'borderline-top'; +const BORDERLINE_BOTTOM = 'borderline-bottom'; +const DRAGGED = 'dragged'; + + +export function PageThumbnail({item, onClick, onThumbnailPageDropped}: Args) { + + const [cssClassNames, setCssClassNames] = useState>([ + 'd-flex', + 'flex-column', + 'p-2', + 'm-2', + 'page', + 'pb-0', + 'page-thumbnail' + ]); + const ref = useRef(null); + + if (!item.page.jpg_url) { return ; } - const {is_loading, data, error} = useProtectedJpg(page.jpg_url); + const {is_loading, data, error} = useProtectedJpg(item.page.jpg_url); let thumbnail_component: JSX.Element | null; const localOnClick = () => { - onClick(page); + onClick(item); + } + + const onLocalDrag = () => { + if (cssClassNames.indexOf(DRAGGED) < 0) { + setCssClassNames([ + ...cssClassNames, DRAGGED + ]); + } + } + + const onLocalDragStart = (event: React.DragEvent) => { + event.dataTransfer.setData(PAGE_ID, item.page.id); + if (cssClassNames.indexOf(DRAGGED) < 0) { + setCssClassNames([ + ...cssClassNames, DRAGGED + ]); + } + } + + const onLocalDragEnd = () => { + setCssClassNames( + cssClassNames.filter(item => item !== DRAGGED) + ); + } + + const onLocalDragOver = (event: React.DragEvent) => { + const y = event.clientY; + + event.preventDefault(); + + if (ref?.current) { + const rect = ref?.current.getBoundingClientRect(); + const half = (rect.bottom - rect.top) / 2; + + if (y >= rect.top && y < rect.top + half) { + // remove borderline_bottom and add borderline_top + const new_array = cssClassNames.filter(i => i != BORDERLINE_BOTTOM); + + if (new_array.indexOf(BORDERLINE_TOP) < 0) { + setCssClassNames([ + ...new_array, BORDERLINE_TOP + ]); + } + } else if (y >= rect.top + half && y < rect.bottom) { + // remove borderline_top and add borderline_bottom + const new_array = cssClassNames.filter(i => i != BORDERLINE_TOP); + + if (new_array.indexOf(BORDERLINE_BOTTOM) < 0) { + setCssClassNames([ + ...new_array, BORDERLINE_BOTTOM + ]); + } + } + } // if (ref?.current) + } // end of onLocalDragOver + + const onLocalDragLeave = () => { + // remove both borderline_bottom and borderline_top + const new_array = cssClassNames.filter(i => i != BORDERLINE_BOTTOM && i != BORDERLINE_TOP); + setCssClassNames( + new_array + ); + } + + const onLocalDrop = (event: React.DragEvent) => { + const source_page_id: string = event.dataTransfer.getData(PAGE_ID); + const y = event.clientY; + let position: DroppedThumbnailPosition = 'before'; + + event.preventDefault(); + + if (ref?.current) { + const rect = ref?.current.getBoundingClientRect(); + const half = (rect.bottom - rect.top) / 2; + + if (y >= rect.top && y < rect.top + half) { + // dropped over upper half of the page + position = 'before'; + } else if (y >= rect.top + half && y < rect.bottom) { + // dropped over lower half of the page + position = 'after'; + } + if (source_page_id != item.page.id) { + onThumbnailPageDropped({ + source_id: source_page_id, + target_id: item.page.id, + position: position + }); + } else { + console.log('Page dropped onto itself'); + } + } // if (ref?.current) + + // remove both borderline_bottom and borderline_top + const new_array = cssClassNames.filter(i => i != BORDERLINE_BOTTOM && i != BORDERLINE_TOP); + setCssClassNames( + new_array + ); + } + + const onLocalDragEnter = (event: React.DragEvent) => { + event.preventDefault(); } if (is_loading) { @@ -28,17 +155,28 @@ export function PageThumbnail({page, onClick}: Args) { } else if ( error ) { thumbnail_component =
Error
} else { - thumbnail_component =
+ thumbnail_component =
{data}
- {page.number} + {item.page.number}
} - return
+ return
{thumbnail_component}
} diff --git a/ui/src/components/viewer/thumbnails_panel/thumbnails_panel.tsx b/ui/src/components/viewer/thumbnails_panel/thumbnails_panel.tsx index 0ff1942f0..38946c214 100644 --- a/ui/src/components/viewer/thumbnails_panel/thumbnails_panel.tsx +++ b/ui/src/components/viewer/thumbnails_panel/thumbnails_panel.tsx @@ -1,24 +1,36 @@ -import type { PageType } from "types" +import type { PageAndRotOp, ThumbnailPageDroppedArgs } from "types" import { PageThumbnail } from "./page_thumbnail"; type Args = { - pages: Array; + pages: Array; visible: boolean; - onClick: (page: PageType) => void; + onClick: (page: PageAndRotOp) => void; + onThumbnailPageDropped: (args: ThumbnailPageDroppedArgs) => void; } -export function ThumbnailsPanel({pages, visible, onClick}: Args) { +export function ThumbnailsPanel({ + pages, + visible, + onClick, + onThumbnailPageDropped +}: Args) { - let css_class_nanme = 'thumbnails-panel'; + let css_class_name = 'thumbnails-panel'; if (!visible) { - css_class_nanme += ' hidden'; + css_class_name += ' hidden'; } return ( -
- {pages.map(page => )} +
+ {pages.map(item => { + return + })}
); } diff --git a/ui/src/components/viewer/viewer.tsx b/ui/src/components/viewer/viewer.tsx index 061b0dc24..2b311f301 100644 --- a/ui/src/components/viewer/viewer.tsx +++ b/ui/src/components/viewer/viewer.tsx @@ -7,9 +7,14 @@ import { ThumbnailsToggle } from "./thumbnails_panel/thumbnails_toggle"; import { fetcher } from 'utils/fetcher'; import { useViewerContentHeight } from 'hooks/viewer_content_height'; +import ActionPanel from "components/viewer/action_panel/action_panel"; import { NodeClickArgsType, DocumentType, DocumentVersion } from "types"; -import type { State, PageType } from 'types'; +import type { PageAndRotOp } from 'types'; +import type { State, ThumbnailPageDroppedArgs } from 'types'; import ErrorMessage from 'components/error_message'; +import { reorder_pages } from 'utils/misc'; + +import { apply_page_op_changes } from 'requests/viewer'; type Args = { @@ -29,6 +34,9 @@ export default function Viewer( } let [{is_loading, error, data}, setDoc] = useState>(initial_breadcrumb_state); let [curDocVer, setCurDocVer] = useState(); + let [curPages, setCurPages] = useState>([]); + let [unappliedPagesOpChanges, setUnappliedPagesOpChanges] = useState(false); + // currentPage = where to scroll into let [currentPage, setCurrentPage] = useState(1); let viewer_content_height = useViewerContentHeight(); const viewer_content_ref = useRef(null); @@ -62,6 +70,7 @@ export default function Viewer( }); setCurDocVer(last_version); + setCurPages(last_version.pages.map(p => { return {page: p, ccw: 0};})); }).catch((error: Error) => { setDoc({ is_loading: false, @@ -76,9 +85,37 @@ export default function Viewer( setThumbnailsPanelVisible(!thumbnailsPanelVisible); } - const onPageThumbnailClick = (page: PageType) => { - console.log(`thumbnail clicked page.number=${page.number}`); - setCurrentPage(page.number); + const onPageThumbnailClick = (item: PageAndRotOp) => { + setCurrentPage(item.page.number); + } + + const onThumbnailPageDropped = ({ + source_id, + target_id, + position + }: ThumbnailPageDroppedArgs) => { + /* + Triggered when page thumbnail is dropped + + source_id = is the id of the page which was dragged and dropped + target_id = is the id of the page over which source was dropped + position = should source page be inserted before or after the target? + Method is triggered only when source_id != target_id. + */ + const new_pages = reorder_pages({ + arr: curPages, + source_id: source_id, + target_id: target_id, + position: position + }); + if (!new_pages.every((value, index) => value.page.id == curPages[index].page.id)) { + setUnappliedPagesOpChanges(true); + } + setCurPages(new_pages); + } + + const onApplyPageOpChanges = async () => { + let response = await apply_page_op_changes(curPages) } if (error) { @@ -88,17 +125,21 @@ export default function Viewer( } return
+
+ onClick={onPageThumbnailClick} + onThumbnailPageDropped={onThumbnailPageDropped} />
; diff --git a/ui/src/hooks/viewer_content_height.tsx b/ui/src/hooks/viewer_content_height.tsx index 2e58a33d3..36c723a09 100644 --- a/ui/src/hooks/viewer_content_height.tsx +++ b/ui/src/hooks/viewer_content_height.tsx @@ -10,7 +10,7 @@ type ComputedHeightArgs = { function get_computed_height( {element_id, element_class, default_value}: ComputedHeightArgs -) { +): number { let el, styles, height; if (element_id) { @@ -38,25 +38,35 @@ function get_computed_height( return height; } -function get_navbar_height() { +function get_navbar_height(): number { return get_computed_height({ element_class: 'nav-top', default_value: 56 }); } -function get_breadcrumb_height() { +function get_breadcrumb_height(): number { return get_computed_height({ element_class: 'nav-breadcrumb', default_value: 40 }); } +function get_action_panel_height(): number { + let result = get_computed_height({ + element_class: 'action-panel', + default_value: 100 + }); + + return result; +} + function get_height() { let height: number = window.innerHeight; height -= get_navbar_height(); height -= get_breadcrumb_height(); + height -= get_action_panel_height(); return height; } diff --git a/ui/src/requests/viewer.ts b/ui/src/requests/viewer.ts new file mode 100644 index 000000000..de9aca045 --- /dev/null +++ b/ui/src/requests/viewer.ts @@ -0,0 +1,9 @@ +import type { PageAndRotOp, PageType } from "types"; +import { fetcher_post } from "utils/fetcher"; + + +export async function apply_page_op_changes( + pages: PageAndRotOp[] +): Promise { + return fetcher_post('/pages/reorder', pages); +} diff --git a/ui/src/styles/viewer.scss b/ui/src/styles/viewer.scss index d45d54434..540cedeee 100644 --- a/ui/src/styles/viewer.scss +++ b/ui/src/styles/viewer.scss @@ -3,6 +3,7 @@ .content { background-color: #666; + overflow-y: hidden; .thumbnails-panel { width: 10rem; @@ -42,11 +43,13 @@ background-color: white; } + .dragged { + opacity: 0.5; + } + img { margin: 0 auto; } - } - } } diff --git a/ui/src/types.ts b/ui/src/types.ts index 5b3a0cb69..400cef4a1 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -187,3 +187,16 @@ export type LoadableTagList = { error: string | null; data: ColoredTagList | null; } + +export type PageAndRotOp = { // page and rotation operation + page: PageType; + ccw: 0; // rotation degree, can be positive or negative +} + + +export type DroppedThumbnailPosition = 'before' | 'after'; +export type ThumbnailPageDroppedArgs = { + source_id: string; + target_id: string; + position: DroppedThumbnailPosition; +} diff --git a/ui/src/utils/misc.ts b/ui/src/utils/misc.ts index 9db240dc1..56f5989c3 100644 --- a/ui/src/utils/misc.ts +++ b/ui/src/utils/misc.ts @@ -1,4 +1,4 @@ -import type { NodeType, NodeList } from 'types'; +import type { NodeType, NodeList, PageAndRotOp, PageType, DroppedThumbnailPosition } from 'types'; import { Rectangle } from 'utils/geometry'; import { Point } from 'utils/geometry'; import { NodeSortFieldEnum, NodeSortOrderEnum } from 'types'; @@ -73,3 +73,99 @@ export function build_nodes_list_params({ return result; } + +type ReorderPagesArgs = { + arr: PageAndRotOp[]; + source_id: string; + target_id: string; + position: DroppedThumbnailPosition; +} + +export function reorder_pages({ + arr, + source_id, + target_id, + position +}: ReorderPagesArgs): PageAndRotOp[] { + /* + Returns an array with reordered pages. + + Items are reordered as follows: source_id wil be positioned + before or after target_id (depending on positioned arg). + Couple of examples. + Example 1: + + arr = [ 1, 2, 3, 4 ] + source_id = 2 + target_id = 4 + position = 'after' + + In other words, item 2 will be positioned after item 4. + Result will be: + + result = [1, 3, 4, 2] + + Example 2: + + arr = [ 1, 2, 3, 4 ] + source_id = 2 + target_id = 4 + position = 'before' + + Result will be (element 2 will be positioned before element 4): + + result = [1, 3, 2, 4] + + Example 3: + arr = [1, 2] + source_id = 2 + target_id = 1 + position = 'before' + + Result will be: + + result = [2, 1] + + Example 4: + + arr = [1, 2] + source_id = 2 + target_id = 1 + position = 'after' + + Result will be: + + result = [1, 2] + + i.e. same as input because source was already after target + */ + let result: PageAndRotOp[] = []; + let insert_now = false; + const source: PageAndRotOp | undefined = arr.find(i => i.page.id == source_id); + + if (!source) { + throw new Error("Source page not found in arr"); + } + + arr.forEach((item: PageAndRotOp) => { + + if (insert_now) { + result.push(source); + insert_now = false; + } + + if (item.page.id !== source_id && item.page.id !== target_id) { + result.push(item); + } else if (item.page.id === target_id) { + if (position == 'before') { + result.push(source); + result.push(item); + } else { + insert_now = true; // will insert source on next iteration + result.push(item); + } + } + }); + + return result; +}