Skip to content

Commit 854f6b5

Browse files
committed
feat: initial specification
1 parent d9b2bf1 commit 854f6b5

File tree

4 files changed

+241
-0
lines changed

4 files changed

+241
-0
lines changed

.vscode/settings.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,5 +21,8 @@
2121
},
2222
"[mdx]": {
2323
"editor.defaultFormatter": "esbenp.prettier-vscode"
24+
},
25+
"[markdown]": {
26+
"editor.defaultFormatter": "esbenp.prettier-vscode"
2427
}
2528
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# Locations
2+
3+
Before understanding what locations are and how they are helpful, we must first understand the structure of a BlockNote document by it's parts:
4+
5+
```ts
6+
type Text = {
7+
type: "text";
8+
text: string;
9+
styles?: Object;
10+
};
11+
12+
type CustomInlineContent = {
13+
type: string;
14+
props: Object;
15+
content: Text[] | undefined;
16+
};
17+
18+
type Block = {
19+
id: string;
20+
type: string;
21+
props: Object;
22+
content: (Text | CustomInlineContent)[] | undefined;
23+
children: Block[] | undefined;
24+
};
25+
26+
type Document = Block[];
27+
```
28+
29+
So, as you can see, the editor's content can be quite layered and nested.
30+
31+
- A document contains blocks
32+
- A block may contain text, or inline content, or be empty altogether
33+
- A block may also have child blocks (which are visually indented)
34+
- inline content may contain text or be empty altogether
35+
36+
Blocks have identifiers, but inline content & text do not have unique identifiers, so it can be tricky to describe their position within the document. Usually this is done relative to the current text cursor, but there needs to be a better way about this than moving the selection around by offsets.
37+
38+
## Enter Locations
39+
40+
A location is meant to be a generic way of describing a position within a document. It is flexible enough to describe a single position within the document (e.g. in-between two letters anywhere within the document) as well as be flexible enough to represent ranges of positions across the document (e.g. the current selected text). This can occur at different levels of specificity, sometimes you care only about block-level operations (e.g. when updating a block's props), other times you care about character level operations (e.g. inserting a character into the document at a specific position), and still other times you care about ranges of positions (e.g. marking a sentence to be bold).
41+
42+
Locations provide this flexibility by being flexible in their definition like below:
43+
44+
- **BlockId**: block level specificity
45+
- Technically just a `Point` where the offset is `-1`
46+
- **Point**: character level specificity
47+
- a pair of a `BlockId` & `offset`
48+
- **Range**: a range of items at character level specificity
49+
- two points `anchor` & `head`
50+
- **BlockRange**: a range of blocks at block level specificity
51+
- Technically just two `Range`s, with both of their offsets as `-1`
52+
53+
A `Location` is defined to be any of `BlockId | Point | Range` allowing for varying levels of specificity. In some cases, operations may only operate at the block-level, so passing something of higher specificity will only operate at that level. In other cases, an operation may require a higher-level of specificity and therefore will be required to declare that at the type-level.
54+
55+
## Example
56+
57+
So, that was a lot of explanation, let's see an example to make this concrete. We will start by defining a document like so:
58+
59+
```ts
60+
const document = [
61+
{
62+
id: "first-paragraph",
63+
type: "paragraph",
64+
props: {},
65+
content: [
66+
{
67+
type: "text",
68+
text: "abc",
69+
},
70+
],
71+
},
72+
{
73+
id: "second-paragraph",
74+
type: "paragraph",
75+
props: {},
76+
content: [
77+
{
78+
type: "text",
79+
text: "def",
80+
},
81+
],
82+
},
83+
];
84+
```
85+
86+
Based on this document, we can see different ways of declaring the positions in this document:
87+
88+
```ts
89+
// selects the whole first paragraph
90+
const a = "first-paragraph" satisfies BlockId;
91+
92+
const b = { id: "first-paragraph" } satisfies BlockIdentifier;
93+
94+
const c = { id: "first-paragraph", offset: -1 } satisfies Point;
95+
96+
const d = {
97+
anchor: { id: "first-paragraph", offset: -1 },
98+
head: { id: "first-paragraph", offset: -1 },
99+
} satisfies Range;
100+
101+
const e = ["first-paragraph", "first-paragraph"] satisfies BlockRange;
102+
103+
// any of the above satisfies as a `Location`
104+
const f = (a || b || c || d || e) satisfies Location;
105+
```
106+
107+
Whereas, if we want to select the position starting at character 'b' then only these are valid:
108+
109+
```ts
110+
const g = { id: "first-paragraph", offset: 1 } satisfies Point;
111+
112+
const h = {
113+
anchor: { id: "first-paragraph", offset: 1 },
114+
head: { id: "first-paragraph", offset: 1 },
115+
} satisfies Range;
116+
```
117+
118+
Or, some other examples:
119+
120+
```ts
121+
// select from character 'b' to character 'c'
122+
const i = {
123+
anchor: { id: "first-paragraph", offset: 1 },
124+
head: { id: "first-paragraph", offset: 2 },
125+
} satisfies Range;
126+
// select from character 'b' to character 'e'
127+
const i = {
128+
anchor: { id: "first-paragraph", offset: 1 },
129+
head: { id: "second-paragraph", offset: 1 },
130+
} satisfies Range;
131+
// select from first paragraph to the second paragraph
132+
const j = {
133+
anchor: { id: "first-paragraph", offset: -1 },
134+
head: { id: "second-paragraph", offset: -1 },
135+
} satisfies Range;
136+
const k = ["first-paragraph", "second-paragraph"] satisfies BlockRange;
137+
```
138+
139+
## Nested content
140+
141+
To interpret nested content, you can imagine Ranges as being inclusive of their children, or exclusive of their children. For example, when out-denting a block with children, you would want that operation to be exclusive of it's children (i.e. only out-dent the parents, not their children), whereas bolding would be inclusive of it's children (i.e. bold should apply to the parent & children selected). As such, this is dependent on the type of operation, not of the `Location` being specified, so methods will need to be clear of their expectations upfront. By default, operations will be **assumed to be _inclusive_ of children**.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/**
2+
* A block id is a unique identifier for a block, it is a string.
3+
*/
4+
export type BlockId = string;
5+
6+
/**
7+
* A block identifier is a unique identifier for a block, it is either a string, or can be object with an id property (out of convenience).
8+
*/
9+
export type BlockIdentifier = { id: BlockId } | BlockId;
10+
11+
/**
12+
* A point is a path with an offset, it is used to identify a specific position within a block.
13+
*/
14+
export type Point = {
15+
id: BlockId;
16+
/**
17+
* The number of characters from the start of the block.
18+
*/
19+
offset: number;
20+
};
21+
22+
/**
23+
* A range is a pair of points, it is used to identify a range of blocks within a document.
24+
*/
25+
export type Range = {
26+
anchor: Point;
27+
head: Point;
28+
};
29+
30+
/**
31+
* A block range is a pair of block identifiers, it is used to identify a range of blocks within a document.
32+
*/
33+
export type BlockRange = [BlockIdentifier, BlockIdentifier];
34+
35+
/**
36+
* A location is a path, point, or range, it is used to identify positions within a document.
37+
*/
38+
export type Location = BlockIdentifier | Point | Range;
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { BlockId, BlockIdentifier, Location, Point, Range } from "./types.js";
2+
3+
export function toId(id: BlockIdentifier): BlockId {
4+
return typeof id === "string" ? id : id.id;
5+
}
6+
7+
export function isBlockId(id: unknown): id is BlockId {
8+
return typeof id === "string";
9+
}
10+
11+
export function isBlockIdentifier(id: unknown): id is BlockIdentifier {
12+
return !!id && typeof id === "object" && "id" in id;
13+
}
14+
15+
export function isPoint(location: unknown): location is Point {
16+
return (
17+
!!location &&
18+
typeof location === "object" &&
19+
"offset" in location &&
20+
typeof location.offset === "number" &&
21+
"id" in location &&
22+
isBlockId(location.id)
23+
);
24+
}
25+
26+
export function isRange(location: unknown): location is Range {
27+
return (
28+
!!location &&
29+
typeof location === "object" &&
30+
"anchor" in location &&
31+
isPoint(location.anchor) &&
32+
"head" in location &&
33+
isPoint(location.head)
34+
);
35+
}
36+
37+
export function isLocation(location: unknown): location is Location {
38+
return isBlockId(location) || isPoint(location) || isRange(location);
39+
}
40+
41+
export function getBlockRange(location: Location): [BlockId, BlockId] {
42+
if (isBlockId(location)) {
43+
return [location, location];
44+
}
45+
46+
if (isBlockIdentifier(location)) {
47+
return [location.id, location.id];
48+
}
49+
50+
if (isPoint(location)) {
51+
return [location.id, location.id];
52+
}
53+
54+
if (isRange(location)) {
55+
return [location.anchor.id, location.head.id];
56+
}
57+
58+
throw new Error("Invalid location", { cause: { location } });
59+
}

0 commit comments

Comments
 (0)