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;
+}