@f12o/markable 2026.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.ja.md +79 -0
- package/README.md +160 -0
- package/dist/chunk-LSUELO2A.js +48 -0
- package/dist/chunk-ROXJDUMI.js +89 -0
- package/dist/core.d.ts +63 -0
- package/dist/core.js +6 -0
- package/dist/dom.d.ts +8 -0
- package/dist/dom.js +6 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +10 -0
- package/dist/vite.d.ts +12 -0
- package/dist/vite.js +442 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hirohito FUJITA
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.ja.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# markable
|
|
2
|
+
|
|
3
|
+
あらゆるものをマーク可能にします。
|
|
4
|
+
|
|
5
|
+
`markable` は、既存アプリの実装を大きく変えずに、構造化されたフィードバック、レビューコメント、書き換え指示を成果物へ紐づけるためのヘッドレスなインタラクションレイヤーです。
|
|
6
|
+
|
|
7
|
+
## モード
|
|
8
|
+
|
|
9
|
+
- **dev / review**: 開発者向けのレビュー注釈を保存し、エージェントやリライトツールが利用できる JSON として扱います。
|
|
10
|
+
- **prod / feedback**: ユーザー向けのフィードバックや問い合わせを、URL・選択範囲・ビューポート・任意コンテキストと一緒に収集します。
|
|
11
|
+
|
|
12
|
+
## インストール
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install @f12o/markable
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
主なサブパスエクスポートは次のとおりです。
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { createMarkable } from "@f12o/markable/core";
|
|
22
|
+
import { createDomAdapter } from "@f12o/markable/dom";
|
|
23
|
+
import { markable } from "@f12o/markable/vite";
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Vite での使い方
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
import { defineConfig } from "vite";
|
|
30
|
+
import { markable } from "@f12o/markable/vite";
|
|
31
|
+
|
|
32
|
+
export default defineConfig({
|
|
33
|
+
plugins: [
|
|
34
|
+
markable({
|
|
35
|
+
mode: "auto",
|
|
36
|
+
commentsFile: ".markable/comments.json",
|
|
37
|
+
endpoint: "/__markable/comments",
|
|
38
|
+
}),
|
|
39
|
+
],
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`mode: "auto"` は Vite の開発時に review モード、本番ビルド時に feedback モードへ解決されます。
|
|
44
|
+
|
|
45
|
+
## UI の使い方
|
|
46
|
+
|
|
47
|
+
画面右下のフローティングボタンから composer を開きます。
|
|
48
|
+
|
|
49
|
+
- ハイライトされた要素をクリックすると、その DOM 要素にマークを紐づけます。
|
|
50
|
+
- 空白部分をドラッグすると、矩形の画面範囲にマークを紐づけます。
|
|
51
|
+
- 対象を選ばずに保存すると、ページ全体へのフィードバックとして記録します。
|
|
52
|
+
- ボタン、composer のタイトル、最近のマーク一覧の見出しはドラッグ可能です。UI が選択したい要素に重なった場合は、任意の場所へ移動できます。
|
|
53
|
+
|
|
54
|
+
開発サーバーでは、投稿された注釈が `.markable/comments.json` に保存されます。静的な GitHub Pages 配信では POST 先がないため、外部エンドポイントを設定しない限りセッション内の表示に留まります。
|
|
55
|
+
|
|
56
|
+
## デモ
|
|
57
|
+
|
|
58
|
+
軽量な Vue 3 + Vite Todo デモ:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pnpm install
|
|
62
|
+
pnpm build
|
|
63
|
+
pnpm --filter @f12o/markable-vite-todo-demo dev
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
より実践的な React ダッシュボードデモ:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pnpm --filter @f12o/markable-shadcn-admin-demo dev
|
|
70
|
+
pnpm --filter @f12o/markable-shadcn-admin-demo build
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
GitHub Pages では次の URL でデモを確認できます。
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
https://f4ah6o.github.io/markable/
|
|
77
|
+
https://f4ah6o.github.io/markable/vue-todo/
|
|
78
|
+
https://f4ah6o.github.io/markable/shadcn-admin/
|
|
79
|
+
```
|
package/README.md
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# markable
|
|
2
|
+
|
|
3
|
+
[日本語版](README.ja.md)
|
|
4
|
+
|
|
5
|
+
Make anything markable.
|
|
6
|
+
|
|
7
|
+
`markable` is a headless interaction layer for attaching structured feedback, review comments, and rewrite annotations to artifacts without changing the existing app implementation.
|
|
8
|
+
|
|
9
|
+
It is designed to work in two modes:
|
|
10
|
+
|
|
11
|
+
- **dev**: developer-oriented review annotations that can be consumed by agents and rewrite tools.
|
|
12
|
+
- **prod**: user-facing feedback and inquiry capture with URL, selection, viewport, and optional context.
|
|
13
|
+
|
|
14
|
+
## Package shape
|
|
15
|
+
|
|
16
|
+
The intended npm entry point is:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @f12o/markable
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Subpath exports are used for integrations:
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import { createMarkable } from "@f12o/markable/core";
|
|
26
|
+
import { createDomAdapter } from "@f12o/markable/dom";
|
|
27
|
+
import { markable } from "@f12o/markable/vite";
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Vite usage
|
|
31
|
+
|
|
32
|
+
```ts
|
|
33
|
+
import { defineConfig } from "vite";
|
|
34
|
+
import { markable } from "@f12o/markable/vite";
|
|
35
|
+
|
|
36
|
+
export default defineConfig({
|
|
37
|
+
plugins: [
|
|
38
|
+
markable({
|
|
39
|
+
mode: process.env.NODE_ENV === "production" ? "feedback" : "review",
|
|
40
|
+
commentsFile: ".markable/comments.json",
|
|
41
|
+
endpoint: "/__markable/comments",
|
|
42
|
+
}),
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Vite+ compatibility
|
|
48
|
+
|
|
49
|
+
Vite+ is expected to run normal Vite plugins when it loads a Vite-compatible config. `markable` keeps the integration as a standard Vite plugin instead of exposing a Vite+-specific API.
|
|
50
|
+
|
|
51
|
+
Initial compatibility target:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
vp dev
|
|
55
|
+
vp build
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The plugin currently uses standard Vite hooks:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
transformIndexHtml
|
|
62
|
+
configureServer
|
|
63
|
+
resolveId
|
|
64
|
+
load
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Core idea
|
|
68
|
+
|
|
69
|
+
```text
|
|
70
|
+
artifact
|
|
71
|
+
-> mark target
|
|
72
|
+
-> annotate / comment / feedback
|
|
73
|
+
-> structured event
|
|
74
|
+
-> ticket / JSON / agent input
|
|
75
|
+
-> rewrite / resolve / follow-up
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`markable` does not own your UI. The core is headless. DOM and Vite integrations provide capture and injection only.
|
|
79
|
+
|
|
80
|
+
## Current status
|
|
81
|
+
|
|
82
|
+
Initial scaffold.
|
|
83
|
+
|
|
84
|
+
## Inspiration
|
|
85
|
+
|
|
86
|
+
The production feedback selection UX is inspired by [`u-ichi/reviewable-html-workbench`](https://github.com/u-ichi/reviewable-html-workbench), particularly its clear review state, contextual highlighting, and visually anchored comments. `markable` generalizes that interaction pattern for production web app feedback while keeping the core package headless.
|
|
87
|
+
|
|
88
|
+
## Demo app
|
|
89
|
+
|
|
90
|
+
A lightweight Vue 3 + Vite Todo demo lives in `examples/vite-todo`. It is intentionally small so the markable integration is easy to inspect:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pnpm install
|
|
94
|
+
pnpm build
|
|
95
|
+
pnpm --filter @f12o/markable-vite-todo-demo dev
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
The demo config uses the package Vite plugin directly:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
markable({
|
|
102
|
+
mode: "auto",
|
|
103
|
+
commentsFile: ".markable/comments.json",
|
|
104
|
+
endpoint: "/__markable/comments",
|
|
105
|
+
});
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
In Vite development mode, `mode: "auto"` resolves to review mode. Use the floating Mark button to open a composer. Practical page elements highlight automatically as you move over them; click a highlighted element to attach the mark to that DOM element, drag an empty page area to attach it to a rectangular screen region, or save without choosing a target to attach it to the current page. The dev server endpoint writes structured annotation JSON to `.markable/comments.json` inside the demo app.
|
|
109
|
+
|
|
110
|
+
In production builds, `mode: "auto"` resolves to feedback mode. The floating Feedback button opens a user-facing feedback panel with Feedback and Question tabs, the same automatic element and box targeting behavior, and an in-session list of recent submissions. Captured context includes URL, title, viewport, user agent, the active tab intent, and the optional selected element or rectangle.
|
|
111
|
+
|
|
112
|
+
### shadcn-admin example
|
|
113
|
+
|
|
114
|
+
A larger React dashboard example lives in `examples/shadcn-admin`. It vendors [`satnaing/shadcn-admin`](https://github.com/satnaing/shadcn-admin) at commit `e16c87f213a5ba5e45964e9b67c792105ec74d26` and adds the markable Vite plugin so the overlay can be exercised against a realistic shadcn UI:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
pnpm install
|
|
118
|
+
pnpm --filter @f12o/markable-shadcn-admin-demo dev
|
|
119
|
+
pnpm --filter @f12o/markable-shadcn-admin-demo build
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
The example config uses the same local development endpoint as the Todo demo:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
markable({
|
|
126
|
+
mode: "auto",
|
|
127
|
+
commentsFile: ".markable/comments.json",
|
|
128
|
+
endpoint: "/__markable/comments",
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
In local development, submitted marks are persisted to `examples/shadcn-admin/.markable/comments.json`.
|
|
133
|
+
|
|
134
|
+
### GitHub Pages deployment
|
|
135
|
+
|
|
136
|
+
The `Deploy demo to GitHub Pages` workflow builds the package, builds the examples, generates an example index, and publishes the static output to GitHub Pages at:
|
|
137
|
+
|
|
138
|
+
```text
|
|
139
|
+
https://f4ah6o.github.io/markable/
|
|
140
|
+
https://f4ah6o.github.io/markable/vue-todo/
|
|
141
|
+
https://f4ah6o.github.io/markable/shadcn-admin/
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
The index page is generated from `examples/examples.json` by `scripts/build-pages-index.mjs`, so new examples can be added to the listing by updating the manifest.
|
|
145
|
+
|
|
146
|
+
GitHub Pages is static hosting, so it can demonstrate the example apps and injected feedback overlay but cannot persist POSTed feedback to `/.markable` or `/.json` files. For public static deployments, treat submitted feedback as local/session-only unless a remote endpoint is configured.
|
|
147
|
+
|
|
148
|
+
### Cloudflare Workers follow-up
|
|
149
|
+
|
|
150
|
+
For persistent production feedback, point the markable endpoint at a Worker route such as `/api/feedback`:
|
|
151
|
+
|
|
152
|
+
```text
|
|
153
|
+
browser
|
|
154
|
+
-> markable feedback UI
|
|
155
|
+
-> /api/feedback
|
|
156
|
+
-> Cloudflare Worker
|
|
157
|
+
-> D1, KV, R2, GitHub Issues, a queue, or a webhook
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
That follow-up can keep the static GitHub Pages demo lightweight while adding real storage, notification, or issue creation behind a Worker-backed endpoint.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// src/core.ts
|
|
2
|
+
function createMarkable(options) {
|
|
3
|
+
const now = options.now ?? (() => /* @__PURE__ */ new Date());
|
|
4
|
+
const idFactory = options.idFactory ?? defaultIdFactory;
|
|
5
|
+
return {
|
|
6
|
+
mode: options.mode,
|
|
7
|
+
load() {
|
|
8
|
+
return options.store.load();
|
|
9
|
+
},
|
|
10
|
+
async submit(message) {
|
|
11
|
+
const target = options.adapter.getTarget();
|
|
12
|
+
if (!target) {
|
|
13
|
+
throw new Error("markable: no active target");
|
|
14
|
+
}
|
|
15
|
+
const timestamp = now().toISOString();
|
|
16
|
+
const annotation = {
|
|
17
|
+
id: idFactory(),
|
|
18
|
+
mode: options.mode,
|
|
19
|
+
target,
|
|
20
|
+
message,
|
|
21
|
+
status: "open",
|
|
22
|
+
context: options.adapter.getContext?.(),
|
|
23
|
+
createdAt: timestamp,
|
|
24
|
+
updatedAt: timestamp
|
|
25
|
+
};
|
|
26
|
+
await options.store.save(annotation);
|
|
27
|
+
options.adapter.clearSelection?.();
|
|
28
|
+
return annotation;
|
|
29
|
+
},
|
|
30
|
+
async updateStatus(id, status) {
|
|
31
|
+
if (!options.store.update) {
|
|
32
|
+
throw new Error("markable: store does not support update");
|
|
33
|
+
}
|
|
34
|
+
await options.store.update(id, {
|
|
35
|
+
status,
|
|
36
|
+
updatedAt: now().toISOString()
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
function defaultIdFactory() {
|
|
42
|
+
const random = Math.random().toString(36).slice(2, 10);
|
|
43
|
+
return `mark-${random}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export {
|
|
47
|
+
createMarkable
|
|
48
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// src/dom.ts
|
|
2
|
+
function createDomAdapter(options = {}) {
|
|
3
|
+
const root = options.root ?? document;
|
|
4
|
+
const doc = root instanceof Document ? root : root.ownerDocument;
|
|
5
|
+
return {
|
|
6
|
+
getTarget() {
|
|
7
|
+
const selection = doc.getSelection();
|
|
8
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const range = selection.getRangeAt(0);
|
|
12
|
+
const container = nearestElement(range.commonAncestorContainer);
|
|
13
|
+
if (!container) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const rect = range.getBoundingClientRect();
|
|
17
|
+
return {
|
|
18
|
+
kind: "dom_range",
|
|
19
|
+
locator: {
|
|
20
|
+
selector: selectorFor(container),
|
|
21
|
+
startOffset: range.startOffset,
|
|
22
|
+
endOffset: range.endOffset
|
|
23
|
+
},
|
|
24
|
+
quote: selection.toString(),
|
|
25
|
+
rect: {
|
|
26
|
+
x: rect.x,
|
|
27
|
+
y: rect.y,
|
|
28
|
+
width: rect.width,
|
|
29
|
+
height: rect.height
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
getContext() {
|
|
34
|
+
return {
|
|
35
|
+
url: globalThis.location?.href,
|
|
36
|
+
title: doc.title,
|
|
37
|
+
viewport: {
|
|
38
|
+
width: globalThis.innerWidth,
|
|
39
|
+
height: globalThis.innerHeight
|
|
40
|
+
},
|
|
41
|
+
userAgent: globalThis.navigator?.userAgent
|
|
42
|
+
};
|
|
43
|
+
},
|
|
44
|
+
clearSelection() {
|
|
45
|
+
doc.getSelection()?.removeAllRanges();
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
function nearestElement(node) {
|
|
50
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
51
|
+
return node;
|
|
52
|
+
}
|
|
53
|
+
return node.parentElement;
|
|
54
|
+
}
|
|
55
|
+
function selectorFor(element) {
|
|
56
|
+
if (element.id) {
|
|
57
|
+
return `#${cssEscape(element.id)}`;
|
|
58
|
+
}
|
|
59
|
+
const parts = [];
|
|
60
|
+
let current = element;
|
|
61
|
+
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body) {
|
|
62
|
+
const currentElement = current;
|
|
63
|
+
const tag = currentElement.tagName.toLowerCase();
|
|
64
|
+
const parent = currentElement.parentElement;
|
|
65
|
+
if (!parent) {
|
|
66
|
+
parts.unshift(tag);
|
|
67
|
+
break;
|
|
68
|
+
}
|
|
69
|
+
const siblings = Array.from(parent.children).filter(
|
|
70
|
+
(child) => child.tagName === currentElement.tagName
|
|
71
|
+
);
|
|
72
|
+
if (siblings.length === 1) {
|
|
73
|
+
parts.unshift(tag);
|
|
74
|
+
} else {
|
|
75
|
+
const index = siblings.indexOf(currentElement) + 1;
|
|
76
|
+
parts.unshift(`${tag}:nth-of-type(${index})`);
|
|
77
|
+
}
|
|
78
|
+
current = parent;
|
|
79
|
+
}
|
|
80
|
+
return parts.join(" > ");
|
|
81
|
+
}
|
|
82
|
+
function cssEscape(value) {
|
|
83
|
+
const escape = globalThis.CSS?.escape;
|
|
84
|
+
return escape ? escape(value) : value.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export {
|
|
88
|
+
createDomAdapter
|
|
89
|
+
};
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
type MarkableMode = "review" | "feedback";
|
|
2
|
+
type MarkableTargetKind = "dom_range" | "dom_element" | "text_range" | "line_range" | "cell_range" | "bbox" | "node" | "edge";
|
|
3
|
+
type MarkableStatus = "open" | "agent_replied" | "applied" | "rejected" | "needs_user_reply" | "resolved";
|
|
4
|
+
interface MarkableRect {
|
|
5
|
+
x: number;
|
|
6
|
+
y: number;
|
|
7
|
+
width: number;
|
|
8
|
+
height: number;
|
|
9
|
+
}
|
|
10
|
+
interface MarkableTarget {
|
|
11
|
+
kind: MarkableTargetKind;
|
|
12
|
+
locator: Record<string, unknown>;
|
|
13
|
+
quote?: string;
|
|
14
|
+
prefix?: string;
|
|
15
|
+
suffix?: string;
|
|
16
|
+
rect?: MarkableRect;
|
|
17
|
+
}
|
|
18
|
+
interface MarkableContext {
|
|
19
|
+
url?: string;
|
|
20
|
+
title?: string;
|
|
21
|
+
viewport?: {
|
|
22
|
+
width: number;
|
|
23
|
+
height: number;
|
|
24
|
+
};
|
|
25
|
+
userAgent?: string;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
interface MarkableAnnotation {
|
|
29
|
+
id: string;
|
|
30
|
+
mode: MarkableMode;
|
|
31
|
+
target: MarkableTarget;
|
|
32
|
+
message: string;
|
|
33
|
+
status: MarkableStatus;
|
|
34
|
+
context?: MarkableContext;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
updatedAt: string;
|
|
37
|
+
}
|
|
38
|
+
interface MarkableStore {
|
|
39
|
+
load(): Promise<MarkableAnnotation[]>;
|
|
40
|
+
save(annotation: MarkableAnnotation): Promise<void>;
|
|
41
|
+
update?(id: string, patch: Partial<MarkableAnnotation>): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
interface MarkableAdapter {
|
|
44
|
+
getTarget(): MarkableTarget | null;
|
|
45
|
+
getContext?(): MarkableContext;
|
|
46
|
+
clearSelection?(): void;
|
|
47
|
+
}
|
|
48
|
+
interface CreateMarkableOptions {
|
|
49
|
+
mode: MarkableMode;
|
|
50
|
+
adapter: MarkableAdapter;
|
|
51
|
+
store: MarkableStore;
|
|
52
|
+
idFactory?: () => string;
|
|
53
|
+
now?: () => Date;
|
|
54
|
+
}
|
|
55
|
+
interface MarkableRuntime {
|
|
56
|
+
mode: MarkableMode;
|
|
57
|
+
load(): Promise<MarkableAnnotation[]>;
|
|
58
|
+
submit(message: string): Promise<MarkableAnnotation>;
|
|
59
|
+
updateStatus(id: string, status: MarkableStatus): Promise<void>;
|
|
60
|
+
}
|
|
61
|
+
declare function createMarkable(options: CreateMarkableOptions): MarkableRuntime;
|
|
62
|
+
|
|
63
|
+
export { type CreateMarkableOptions, type MarkableAdapter, type MarkableAnnotation, type MarkableContext, type MarkableMode, type MarkableRect, type MarkableRuntime, type MarkableStatus, type MarkableStore, type MarkableTarget, type MarkableTargetKind, createMarkable };
|
package/dist/core.js
ADDED
package/dist/dom.d.ts
ADDED
package/dist/dom.js
ADDED
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
1
|
+
export { CreateMarkableOptions, MarkableAdapter, MarkableAnnotation, MarkableContext, MarkableMode, MarkableRect, MarkableRuntime, MarkableStatus, MarkableStore, MarkableTarget, MarkableTargetKind, createMarkable } from './core.js';
|
|
2
|
+
export { DomAdapterOptions, createDomAdapter } from './dom.js';
|
package/dist/index.js
ADDED
package/dist/vite.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Plugin } from 'vite';
|
|
2
|
+
import { MarkableMode } from './core.js';
|
|
3
|
+
|
|
4
|
+
interface MarkableViteOptions {
|
|
5
|
+
mode?: MarkableMode | "auto";
|
|
6
|
+
commentsFile?: string;
|
|
7
|
+
endpoint?: string;
|
|
8
|
+
inject?: boolean;
|
|
9
|
+
}
|
|
10
|
+
declare function markable(options?: MarkableViteOptions): Plugin;
|
|
11
|
+
|
|
12
|
+
export { type MarkableViteOptions, markable };
|
package/dist/vite.js
ADDED
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
// src/vite.ts
|
|
2
|
+
import fs from "fs/promises";
|
|
3
|
+
import path from "path";
|
|
4
|
+
function markable(options = {}) {
|
|
5
|
+
const endpoint = options.endpoint ?? "/__markable/comments";
|
|
6
|
+
const commentsFile = options.commentsFile ?? ".markable/comments.json";
|
|
7
|
+
const inject = options.inject ?? true;
|
|
8
|
+
let root = process.cwd();
|
|
9
|
+
let resolvedMode = "review";
|
|
10
|
+
return {
|
|
11
|
+
name: "markable",
|
|
12
|
+
configResolved(config) {
|
|
13
|
+
root = config.root;
|
|
14
|
+
resolvedMode = resolveMode(options.mode, config.mode);
|
|
15
|
+
},
|
|
16
|
+
transformIndexHtml() {
|
|
17
|
+
if (!inject) return [];
|
|
18
|
+
return [
|
|
19
|
+
{
|
|
20
|
+
tag: "script",
|
|
21
|
+
attrs: {
|
|
22
|
+
type: "module"
|
|
23
|
+
},
|
|
24
|
+
children: clientSource(endpoint, resolvedMode),
|
|
25
|
+
injectTo: "body"
|
|
26
|
+
}
|
|
27
|
+
];
|
|
28
|
+
},
|
|
29
|
+
configureServer(server) {
|
|
30
|
+
server.middlewares.use(endpoint, async (req, res) => {
|
|
31
|
+
const file = path.resolve(root, commentsFile);
|
|
32
|
+
if (req.method === "GET") {
|
|
33
|
+
const annotations = await readAnnotations(file);
|
|
34
|
+
sendJson(res, { annotations });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (req.method === "POST") {
|
|
38
|
+
const body = await readBody(req);
|
|
39
|
+
const incoming = JSON.parse(body);
|
|
40
|
+
const annotations = await readAnnotations(file);
|
|
41
|
+
annotations.push(incoming);
|
|
42
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
43
|
+
await fs.writeFile(file, JSON.stringify({ annotations }, null, 2));
|
|
44
|
+
sendJson(res, { ok: true, annotation: incoming });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
res.statusCode = 405;
|
|
48
|
+
res.end("Method Not Allowed");
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
resolveId(id) {
|
|
52
|
+
if (id === "/@markable/client") return id;
|
|
53
|
+
},
|
|
54
|
+
load(id) {
|
|
55
|
+
if (id !== "/@markable/client") return null;
|
|
56
|
+
return clientSource(endpoint, resolvedMode);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function resolveMode(mode, viteMode) {
|
|
61
|
+
if (mode === "review" || mode === "feedback") return mode;
|
|
62
|
+
return viteMode === "production" ? "feedback" : "review";
|
|
63
|
+
}
|
|
64
|
+
async function readAnnotations(file) {
|
|
65
|
+
try {
|
|
66
|
+
const raw = await fs.readFile(file, "utf8");
|
|
67
|
+
const parsed = JSON.parse(raw);
|
|
68
|
+
return parsed.annotations ?? [];
|
|
69
|
+
} catch {
|
|
70
|
+
return [];
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function readBody(req) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
let body = "";
|
|
76
|
+
req.on("data", (chunk) => {
|
|
77
|
+
body += chunk;
|
|
78
|
+
});
|
|
79
|
+
req.on("end", () => resolve(body));
|
|
80
|
+
req.on("error", reject);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function sendJson(res, value) {
|
|
84
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
85
|
+
res.end(JSON.stringify(value));
|
|
86
|
+
}
|
|
87
|
+
function clientSource(endpoint, mode) {
|
|
88
|
+
return `
|
|
89
|
+
const endpoint = ${JSON.stringify(endpoint)};
|
|
90
|
+
const mode = ${JSON.stringify(mode)};
|
|
91
|
+
let candidateElement = null;
|
|
92
|
+
let selectedElement = null;
|
|
93
|
+
let selectedTarget = null;
|
|
94
|
+
let dragging = false;
|
|
95
|
+
let dragStart = null;
|
|
96
|
+
let activeTab = "primary";
|
|
97
|
+
const annotations = [];
|
|
98
|
+
|
|
99
|
+
const labels = {
|
|
100
|
+
review: {
|
|
101
|
+
launcher: "\u30DE\u30FC\u30AF",
|
|
102
|
+
panelTitle: "\u3053\u306E\u30DA\u30FC\u30B8\u3092\u30DE\u30FC\u30AF",
|
|
103
|
+
tabPrimary: "\u30B3\u30E1\u30F3\u30C8",
|
|
104
|
+
tabSecondary: "AI\u306B\u4F9D\u983C",
|
|
105
|
+
placeholder: "\u30EC\u30D3\u30E5\u30FC\u30B3\u30E1\u30F3\u30C8\u3092\u5165\u529B",
|
|
106
|
+
submit: "\u30DE\u30FC\u30AF\u3092\u4FDD\u5B58",
|
|
107
|
+
helper: "\u30CF\u30A4\u30E9\u30A4\u30C8\u3055\u308C\u305F\u8981\u7D20\u3092\u30AF\u30EA\u30C3\u30AF\u3001\u7A7A\u767D\u3092\u30C9\u30E9\u30C3\u30B0\u3001\u307E\u305F\u306F\u30DA\u30FC\u30B8\u5168\u4F53\u306E\u30D5\u30A3\u30FC\u30C9\u30D0\u30C3\u30AF\u3092\u4FDD\u5B58\u3067\u304D\u307E\u3059\u3002",
|
|
108
|
+
empty: "\u307E\u3060\u30DE\u30FC\u30AF\u306F\u3042\u308A\u307E\u305B\u3093\u3002"
|
|
109
|
+
},
|
|
110
|
+
feedback: {
|
|
111
|
+
launcher: "\u30D5\u30A3\u30FC\u30C9\u30D0\u30C3\u30AF",
|
|
112
|
+
panelTitle: "\u30D5\u30A3\u30FC\u30C9\u30D0\u30C3\u30AF\u3092\u9001\u4FE1",
|
|
113
|
+
tabPrimary: "\u30D5\u30A3\u30FC\u30C9\u30D0\u30C3\u30AF",
|
|
114
|
+
tabSecondary: "\u8CEA\u554F",
|
|
115
|
+
placeholder: "\u3053\u306E\u30DA\u30FC\u30B8\u3078\u306E\u30D5\u30A3\u30FC\u30C9\u30D0\u30C3\u30AF\u3092\u5165\u529B",
|
|
116
|
+
submit: "\u9001\u4FE1",
|
|
117
|
+
helper: "\u30CF\u30A4\u30E9\u30A4\u30C8\u3055\u308C\u305F\u8981\u7D20\u3092\u30AF\u30EA\u30C3\u30AF\u3001\u7A7A\u767D\u3092\u30C9\u30E9\u30C3\u30B0\u3001\u307E\u305F\u306F\u30DA\u30FC\u30B8\u5168\u4F53\u306E\u30D5\u30A3\u30FC\u30C9\u30D0\u30C3\u30AF\u3092\u9001\u4FE1\u3067\u304D\u307E\u3059\u3002",
|
|
118
|
+
empty: "\u3053\u306E\u30BB\u30C3\u30B7\u30E7\u30F3\u3067\u306F\u307E\u3060\u30D5\u30A3\u30FC\u30C9\u30D0\u30C3\u30AF\u304C\u3042\u308A\u307E\u305B\u3093\u3002"
|
|
119
|
+
}
|
|
120
|
+
}[mode];
|
|
121
|
+
|
|
122
|
+
function applyBaseStyles(element) {
|
|
123
|
+
element.style.fontFamily = 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
|
124
|
+
element.style.fontSize = "14px";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function createLauncher() {
|
|
128
|
+
const launcher = document.createElement("button");
|
|
129
|
+
launcher.type = "button";
|
|
130
|
+
launcher.setAttribute("data-markable-launcher", "");
|
|
131
|
+
launcher.textContent = labels.launcher;
|
|
132
|
+
applyBaseStyles(launcher);
|
|
133
|
+
launcher.style.position = "fixed";
|
|
134
|
+
launcher.style.right = "20px";
|
|
135
|
+
launcher.style.bottom = "20px";
|
|
136
|
+
launcher.style.zIndex = "2147483647";
|
|
137
|
+
launcher.style.border = "0";
|
|
138
|
+
launcher.style.borderRadius = "999px";
|
|
139
|
+
launcher.style.padding = "12px 16px";
|
|
140
|
+
launcher.style.background = mode === "feedback" ? "#111827" : "#2563eb";
|
|
141
|
+
launcher.style.color = "#fff";
|
|
142
|
+
launcher.style.boxShadow = "0 14px 36px rgba(0, 0, 0, 0.24)";
|
|
143
|
+
launcher.style.cursor = "pointer";
|
|
144
|
+
return launcher;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function createPanel() {
|
|
148
|
+
const panel = document.createElement("form");
|
|
149
|
+
panel.setAttribute("data-markable-panel", "");
|
|
150
|
+
applyBaseStyles(panel);
|
|
151
|
+
panel.style.position = "fixed";
|
|
152
|
+
panel.style.right = "20px";
|
|
153
|
+
panel.style.bottom = "76px";
|
|
154
|
+
panel.style.zIndex = "2147483647";
|
|
155
|
+
panel.style.display = "none";
|
|
156
|
+
panel.style.background = "#fff";
|
|
157
|
+
panel.style.color = "#111827";
|
|
158
|
+
panel.style.border = "1px solid rgba(17, 24, 39, 0.14)";
|
|
159
|
+
panel.style.padding = "16px";
|
|
160
|
+
panel.style.borderRadius = "18px";
|
|
161
|
+
panel.style.boxShadow = "0 24px 70px rgba(15, 23, 42, 0.28)";
|
|
162
|
+
panel.style.width = "min(392px, calc(100vw - 32px))";
|
|
163
|
+
panel.innerHTML = '<div style="display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:12px"><strong data-markable-title data-markable-drag-handle style="font-size:16px;cursor:move;user-select:none">' + labels.panelTitle + '</strong><button type="button" data-markable-close aria-label="Close" style="border:0;background:transparent;font-size:20px;line-height:1;cursor:pointer;color:#6b7280">\xD7</button></div><div data-markable-tabs style="display:grid;grid-template-columns:1fr 1fr;padding:3px;border-radius:999px;background:#f3f4f6;margin-bottom:12px"><button type="button" data-markable-tab="primary" style="border:0;border-radius:999px;padding:8px;background:#fff;color:#111827;box-shadow:0 1px 3px rgba(0,0,0,.08);cursor:pointer">' + labels.tabPrimary + '</button><button type="button" data-markable-tab="secondary" style="border:0;border-radius:999px;padding:8px;background:transparent;color:#6b7280;cursor:pointer">' + labels.tabSecondary + '</button></div><p data-markable-target-summary style="margin:0 0 8px;color:#4b5563;font-size:12px">' + labels.helper + '</p><textarea name="message" required data-markable-input style="box-sizing:border-box;width:100%;min-height:104px;border:1px solid #d1d5db;border-radius:12px;padding:10px;resize:vertical"></textarea><div style="display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:10px"><button type="button" data-markable-cancel style="border:1px solid #d1d5db;background:#fff;border-radius:999px;padding:8px 12px;cursor:pointer">\u30AD\u30E3\u30F3\u30BB\u30EB</button><button type="submit" data-markable-submit style="border:0;background:#2563eb;color:#fff;border-radius:999px;padding:8px 14px;cursor:pointer">' + labels.submit + '</button></div><p data-markable-status role="status" style="min-height:16px;margin:8px 0 0;color:#4b5563;font-size:12px"></p>';
|
|
164
|
+
panel.querySelector("[data-markable-input]").placeholder = labels.placeholder;
|
|
165
|
+
return panel;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function createOverlay(kind) {
|
|
169
|
+
const overlay = document.createElement("div");
|
|
170
|
+
overlay.setAttribute(kind === "box" ? "data-markable-box" : "data-markable-highlight", "");
|
|
171
|
+
overlay.style.position = "fixed";
|
|
172
|
+
overlay.style.zIndex = "2147483646";
|
|
173
|
+
overlay.style.pointerEvents = "none";
|
|
174
|
+
overlay.style.border = kind === "box" ? "2px dashed #f59e0b" : "2px solid #2563eb";
|
|
175
|
+
overlay.style.background = kind === "box" ? "rgba(245,158,11,.12)" : "rgba(37,99,235,.08)";
|
|
176
|
+
overlay.style.borderRadius = "8px";
|
|
177
|
+
overlay.style.boxShadow = "0 0 0 4px rgba(37, 99, 235, 0.12)";
|
|
178
|
+
overlay.style.display = "none";
|
|
179
|
+
return overlay;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function createList() {
|
|
183
|
+
const list = document.createElement("aside");
|
|
184
|
+
list.setAttribute("data-markable-list", "");
|
|
185
|
+
applyBaseStyles(list);
|
|
186
|
+
list.style.position = "fixed";
|
|
187
|
+
list.style.left = "20px";
|
|
188
|
+
list.style.bottom = "20px";
|
|
189
|
+
list.style.zIndex = "2147483645";
|
|
190
|
+
list.style.width = "min(320px, calc(100vw - 40px))";
|
|
191
|
+
list.style.maxHeight = "40vh";
|
|
192
|
+
list.style.overflow = "auto";
|
|
193
|
+
list.style.display = "none";
|
|
194
|
+
list.style.border = "1px solid rgba(17,24,39,.14)";
|
|
195
|
+
list.style.borderRadius = "16px";
|
|
196
|
+
list.style.background = "rgba(255,255,255,.96)";
|
|
197
|
+
list.style.boxShadow = "0 18px 46px rgba(15,23,42,.18)";
|
|
198
|
+
list.style.padding = "10px";
|
|
199
|
+
return list;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isMarkableElement(element) {
|
|
203
|
+
return Boolean(element?.closest?.("[data-markable-launcher], [data-markable-panel], [data-markable-highlight], [data-markable-box], [data-markable-list]"));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function selectorFor(element) {
|
|
207
|
+
if (element.id) return "#" + cssEscape(element.id);
|
|
208
|
+
const parts = [];
|
|
209
|
+
let current = element;
|
|
210
|
+
while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body) {
|
|
211
|
+
const tag = current.tagName.toLowerCase();
|
|
212
|
+
const parent = current.parentElement;
|
|
213
|
+
if (!parent) { parts.unshift(tag); break; }
|
|
214
|
+
const siblings = Array.from(parent.children).filter(child => child.tagName === current.tagName);
|
|
215
|
+
parts.unshift(siblings.length === 1 ? tag : tag + ":nth-of-type(" + (siblings.indexOf(current) + 1) + ")");
|
|
216
|
+
current = parent;
|
|
217
|
+
}
|
|
218
|
+
return parts.join(" > ");
|
|
219
|
+
}
|
|
220
|
+
function cssEscape(value) { return window.CSS?.escape ? window.CSS.escape(value) : value.replace(/[^a-zA-Z0-9_-]/g, "\\$&"); }
|
|
221
|
+
function rectObject(rect) { return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; }
|
|
222
|
+
|
|
223
|
+
function elementTarget(element) {
|
|
224
|
+
const rect = element.getBoundingClientRect();
|
|
225
|
+
return { kind: "dom_element", locator: { tag: element.tagName.toLowerCase(), selector: selectorFor(element), dataMarkableId: element.getAttribute("data-markable-id") || undefined, id: element.id || undefined, classes: element.classList.length ? Array.from(element.classList).slice(0, 8) : undefined, ariaLabel: element.getAttribute("aria-label") || undefined, role: element.getAttribute("role") || undefined, textSnippet: (element.innerText || element.textContent || "").trim().replace(/s+/g, " ").slice(0, 160) || undefined }, rect: rectObject(rect) };
|
|
226
|
+
}
|
|
227
|
+
function bboxTarget(rect) { return { kind: "bbox", locator: { url: location.href }, rect: rectObject(rect) }; }
|
|
228
|
+
function currentPageTarget() { return { kind: "dom_range", locator: { url: location.href } }; }
|
|
229
|
+
function currentTarget() { return selectedTarget || currentPageTarget(); }
|
|
230
|
+
function context() { return { url: location.href, title: document.title, viewport: { width: innerWidth, height: innerHeight }, userAgent: navigator.userAgent, markableTab: activeTab }; }
|
|
231
|
+
|
|
232
|
+
function positionOverlay(targetOverlay, rect) { targetOverlay.style.display = "block"; targetOverlay.style.left = rect.x + "px"; targetOverlay.style.top = rect.y + "px"; targetOverlay.style.width = rect.width + "px"; targetOverlay.style.height = rect.height + "px"; }
|
|
233
|
+
function showOverlayFor(element) { positionOverlay(overlay, element.getBoundingClientRect()); }
|
|
234
|
+
function hideOverlay(targetOverlay) { targetOverlay.style.display = "none"; }
|
|
235
|
+
function isPanelOpen() { return panel.style.display !== "none"; }
|
|
236
|
+
function practicalElementFor(element) {
|
|
237
|
+
if (!element || isMarkableElement(element)) return null;
|
|
238
|
+
const marked = element.closest("[data-markable-id]");
|
|
239
|
+
if (marked) return marked;
|
|
240
|
+
return element.closest("button, a, input, textarea, select, label, [role], [aria-label], li, article, section, form");
|
|
241
|
+
}
|
|
242
|
+
function targetAtPoint(clientX, clientY) {
|
|
243
|
+
const element = document.elementFromPoint(clientX, clientY);
|
|
244
|
+
return practicalElementFor(element);
|
|
245
|
+
}
|
|
246
|
+
function rectFromPoints(a, b) { const x = Math.min(a.x, b.x); const y = Math.min(a.y, b.y); return new DOMRect(x, y, Math.abs(a.x - b.x), Math.abs(a.y - b.y)); }
|
|
247
|
+
|
|
248
|
+
function setTab(tab) {
|
|
249
|
+
activeTab = tab;
|
|
250
|
+
const input = panel.querySelector("[data-markable-input]");
|
|
251
|
+
input.placeholder = tab === "secondary" ? (mode === "feedback" ? "\u3053\u306E\u30DA\u30FC\u30B8\u306B\u3064\u3044\u3066\u8CEA\u554F\u3059\u308B" : "AI\u306B\u4F9D\u983C\u3057\u305F\u3044\u5909\u66F4\u5185\u5BB9\u3092\u5165\u529B") : labels.placeholder;
|
|
252
|
+
panel.querySelectorAll("[data-markable-tab]").forEach(button => {
|
|
253
|
+
const active = button.getAttribute("data-markable-tab") === tab;
|
|
254
|
+
button.style.background = active ? "#fff" : "transparent";
|
|
255
|
+
button.style.color = active ? "#111827" : "#6b7280";
|
|
256
|
+
button.style.boxShadow = active ? "0 1px 3px rgba(0,0,0,.08)" : "none";
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
function summarizeTarget(target) {
|
|
260
|
+
if (target?.kind === "dom_element") { const l = target.locator; return "\u5BFE\u8C61: " + (l.dataMarkableId || l.id || l.ariaLabel || l.selector || l.tag); }
|
|
261
|
+
if (target?.kind === "bbox") return "\u5BFE\u8C61: \u9078\u629E\u3057\u305F\u753B\u9762\u7BC4\u56F2";
|
|
262
|
+
return "\u5BFE\u8C61: \u73FE\u5728\u306E\u30DA\u30FC\u30B8";
|
|
263
|
+
}
|
|
264
|
+
function updateSelectedTarget(target) {
|
|
265
|
+
selectedTarget = target || null;
|
|
266
|
+
panel.querySelector("[data-markable-target-summary]").textContent = summarizeTarget(selectedTarget);
|
|
267
|
+
}
|
|
268
|
+
function updateSelectedElement(element) {
|
|
269
|
+
selectedElement = element || null;
|
|
270
|
+
if (selectedElement) showOverlayFor(selectedElement);
|
|
271
|
+
}
|
|
272
|
+
function openPanel(target) {
|
|
273
|
+
updateSelectedTarget(target);
|
|
274
|
+
panel.style.display = "block";
|
|
275
|
+
launcher.style.display = "none";
|
|
276
|
+
panel.querySelector("[data-markable-input]").focus();
|
|
277
|
+
}
|
|
278
|
+
function resetTargeting() { candidateElement = null; selectedElement = null; dragging = false; dragStart = null; selectedTarget = null; hideOverlay(overlay); hideOverlay(boxOverlay); }
|
|
279
|
+
function closePanel() { panel.style.display = "none"; launcher.style.display = "block"; resetTargeting(); }
|
|
280
|
+
function renderList() {
|
|
281
|
+
list.style.display = annotations.length ? "block" : "none";
|
|
282
|
+
list.innerHTML = '<strong data-markable-drag-handle style="display:block;margin-bottom:8px;cursor:move;user-select:none">\u6700\u8FD1\u306E' + (mode === "feedback" ? "\u30D5\u30A3\u30FC\u30C9\u30D0\u30C3\u30AF" : "\u30DE\u30FC\u30AF") + '</strong>' + (annotations.length ? annotations.slice(-4).reverse().map(item => '<article style="border-top:1px solid #e5e7eb;padding-top:8px;margin-top:8px"><div style="display:flex;align-items:flex-start;justify-content:space-between;gap:8px"><div style="min-width:0"><p style="margin:0 0 4px;color:#111827">' + escapeHtml(item.message).slice(0, 140) + '</p><small style="color:#6b7280">' + item.target.kind + ' \xB7 ' + new Date(item.createdAt).toLocaleTimeString() + '</small></div><button type="button" data-markable-copy-json="' + escapeHtml(item.id) + '" aria-label="\u30DE\u30FC\u30AFJSON\u3092\u30B3\u30D4\u30FC" title="JSON\u3092\u30B3\u30D4\u30FC" style="flex:0 0 auto;border:1px solid #d1d5db;background:#fff;color:#374151;border-radius:999px;padding:4px 8px;font-size:12px;line-height:1.2;cursor:pointer">JSON</button></div></article>').join('') : '<p style="margin:0;color:#6b7280">' + labels.empty + '</p>');
|
|
283
|
+
}
|
|
284
|
+
function escapeHtml(value) { return String(value).replace(/[&<>"]/g, char => ({'&':'&','<':'<','>':'>','"':'"'}[char])); }
|
|
285
|
+
|
|
286
|
+
function makeDraggable(element, options = {}) {
|
|
287
|
+
let drag = null;
|
|
288
|
+
const handleSelector = options.handleSelector;
|
|
289
|
+
element.addEventListener("pointerdown", event => {
|
|
290
|
+
if (event.button !== 0) return;
|
|
291
|
+
if (handleSelector && !event.target.closest?.(handleSelector)) return;
|
|
292
|
+
if (!handleSelector && event.target.closest?.("button, input, textarea, select, a")) return;
|
|
293
|
+
const rect = element.getBoundingClientRect();
|
|
294
|
+
drag = { pointerId: event.pointerId, offsetX: event.clientX - rect.left, offsetY: event.clientY - rect.top, startX: event.clientX, startY: event.clientY, moved: false };
|
|
295
|
+
element.setPointerCapture?.(event.pointerId);
|
|
296
|
+
event.preventDefault();
|
|
297
|
+
event.stopPropagation();
|
|
298
|
+
});
|
|
299
|
+
element.addEventListener("pointermove", event => {
|
|
300
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
301
|
+
const maxLeft = Math.max(0, innerWidth - element.offsetWidth);
|
|
302
|
+
const maxTop = Math.max(0, innerHeight - element.offsetHeight);
|
|
303
|
+
drag.moved = drag.moved || Math.abs(event.clientX - drag.startX) > 3 || Math.abs(event.clientY - drag.startY) > 3;
|
|
304
|
+
const left = Math.min(Math.max(0, event.clientX - drag.offsetX), maxLeft);
|
|
305
|
+
const top = Math.min(Math.max(0, event.clientY - drag.offsetY), maxTop);
|
|
306
|
+
element.style.left = left + "px";
|
|
307
|
+
element.style.top = top + "px";
|
|
308
|
+
element.style.right = "auto";
|
|
309
|
+
element.style.bottom = "auto";
|
|
310
|
+
});
|
|
311
|
+
element.addEventListener("pointerup", event => {
|
|
312
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
313
|
+
element.releasePointerCapture?.(event.pointerId);
|
|
314
|
+
if (drag.moved) element.dataset.markableSuppressClickUntil = String(Date.now() + 250);
|
|
315
|
+
drag = null;
|
|
316
|
+
});
|
|
317
|
+
element.addEventListener("pointercancel", event => {
|
|
318
|
+
if (!drag || drag.pointerId !== event.pointerId) return;
|
|
319
|
+
element.releasePointerCapture?.(event.pointerId);
|
|
320
|
+
drag = null;
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async function copyText(value) {
|
|
325
|
+
if (navigator.clipboard?.writeText) {
|
|
326
|
+
try {
|
|
327
|
+
await navigator.clipboard.writeText(value);
|
|
328
|
+
return true;
|
|
329
|
+
} catch {
|
|
330
|
+
// Fall back for embedded browsers that expose clipboard but reject writes.
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const input = document.createElement("textarea");
|
|
334
|
+
input.value = value;
|
|
335
|
+
input.style.position = "fixed";
|
|
336
|
+
input.style.left = "-9999px";
|
|
337
|
+
document.body.append(input);
|
|
338
|
+
input.select();
|
|
339
|
+
const copied = document.execCommand("copy");
|
|
340
|
+
input.remove();
|
|
341
|
+
return copied;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const launcher = document.querySelector("[data-markable-launcher]") || createLauncher();
|
|
345
|
+
const panel = document.querySelector("[data-markable-panel]") || createPanel();
|
|
346
|
+
const overlay = document.querySelector("[data-markable-highlight]") || createOverlay("element");
|
|
347
|
+
const boxOverlay = document.querySelector("[data-markable-box]") || createOverlay("box");
|
|
348
|
+
const list = document.querySelector("[data-markable-list]") || createList();
|
|
349
|
+
for (const node of [launcher, panel, overlay, boxOverlay, list]) if (!node.isConnected) document.body.append(node);
|
|
350
|
+
makeDraggable(launcher);
|
|
351
|
+
makeDraggable(panel, { handleSelector: "[data-markable-drag-handle]" });
|
|
352
|
+
makeDraggable(list, { handleSelector: "[data-markable-drag-handle]" });
|
|
353
|
+
setTab("primary"); renderList();
|
|
354
|
+
|
|
355
|
+
launcher.addEventListener("click", () => {
|
|
356
|
+
if (Number(launcher.dataset.markableSuppressClickUntil || 0) > Date.now()) return;
|
|
357
|
+
openPanel(null);
|
|
358
|
+
});
|
|
359
|
+
panel.querySelector("[data-markable-close]").addEventListener("click", closePanel);
|
|
360
|
+
panel.querySelector("[data-markable-cancel]").addEventListener("click", closePanel);
|
|
361
|
+
panel.querySelectorAll("[data-markable-tab]").forEach(button => button.addEventListener("click", () => setTab(button.getAttribute("data-markable-tab"))));
|
|
362
|
+
list.addEventListener("click", async event => {
|
|
363
|
+
const button = event.target.closest?.("[data-markable-copy-json]");
|
|
364
|
+
if (!button) return;
|
|
365
|
+
const annotation = annotations.find(item => item.id === button.getAttribute("data-markable-copy-json"));
|
|
366
|
+
if (!annotation) return;
|
|
367
|
+
try {
|
|
368
|
+
const copied = await copyText(JSON.stringify(annotation, null, 2));
|
|
369
|
+
button.textContent = copied ? "\u30B3\u30D4\u30FC\u6E08\u307F" : "\u30B3\u30D4\u30FC\u5931\u6557";
|
|
370
|
+
} catch {
|
|
371
|
+
button.textContent = "\u30B3\u30D4\u30FC\u5931\u6557";
|
|
372
|
+
}
|
|
373
|
+
setTimeout(() => { button.textContent = "JSON"; }, 1200);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
document.addEventListener("pointermove", event => {
|
|
377
|
+
if (!isPanelOpen()) return;
|
|
378
|
+
if (dragging) { positionOverlay(boxOverlay, rectFromPoints(dragStart, { x: event.clientX, y: event.clientY })); return; }
|
|
379
|
+
const element = targetAtPoint(event.clientX, event.clientY);
|
|
380
|
+
if (!element) { candidateElement = null; if (!selectedElement) hideOverlay(overlay); return; }
|
|
381
|
+
candidateElement = element;
|
|
382
|
+
if (selectedElement) return;
|
|
383
|
+
showOverlayFor(element);
|
|
384
|
+
}, true);
|
|
385
|
+
document.addEventListener("click", event => {
|
|
386
|
+
if (!isPanelOpen()) return;
|
|
387
|
+
const element = targetAtPoint(event.clientX, event.clientY);
|
|
388
|
+
if (!element) return;
|
|
389
|
+
event.preventDefault();
|
|
390
|
+
event.stopPropagation();
|
|
391
|
+
updateSelectedElement(element);
|
|
392
|
+
updateSelectedTarget(elementTarget(element));
|
|
393
|
+
}, true);
|
|
394
|
+
document.addEventListener("pointerdown", event => {
|
|
395
|
+
if (!isPanelOpen() || isMarkableElement(event.target)) return;
|
|
396
|
+
const element = targetAtPoint(event.clientX, event.clientY);
|
|
397
|
+
if (element) return;
|
|
398
|
+
dragging = true;
|
|
399
|
+
dragStart = { x: event.clientX, y: event.clientY };
|
|
400
|
+
candidateElement = null;
|
|
401
|
+
updateSelectedElement(null);
|
|
402
|
+
hideOverlay(overlay);
|
|
403
|
+
positionOverlay(boxOverlay, new DOMRect(dragStart.x, dragStart.y, 0, 0));
|
|
404
|
+
event.preventDefault();
|
|
405
|
+
}, true);
|
|
406
|
+
document.addEventListener("pointerup", event => {
|
|
407
|
+
if (!dragging) return;
|
|
408
|
+
dragging = false;
|
|
409
|
+
const rect = rectFromPoints(dragStart, { x: event.clientX, y: event.clientY });
|
|
410
|
+
dragStart = null;
|
|
411
|
+
if (rect.width > 8 && rect.height > 8) {
|
|
412
|
+
updateSelectedTarget(bboxTarget(rect));
|
|
413
|
+
} else {
|
|
414
|
+
hideOverlay(boxOverlay);
|
|
415
|
+
}
|
|
416
|
+
}, true);
|
|
417
|
+
|
|
418
|
+
panel.addEventListener("submit", async event => {
|
|
419
|
+
event.preventDefault();
|
|
420
|
+
const message = String(new FormData(panel).get("message") || "").trim();
|
|
421
|
+
if (!message) return;
|
|
422
|
+
const now = new Date().toISOString();
|
|
423
|
+
const annotation = { id: "mark-" + Math.random().toString(36).slice(2, 10), mode, target: currentTarget(), message, status: "open", context: context(), createdAt: now, updatedAt: now };
|
|
424
|
+
const status = panel.querySelector("[data-markable-status]");
|
|
425
|
+
try {
|
|
426
|
+
const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(annotation) });
|
|
427
|
+
if (!response.ok) throw new Error("Request failed with " + response.status);
|
|
428
|
+
status.textContent = mode === "feedback" ? "\u3042\u308A\u304C\u3068\u3046\u3054\u3056\u3044\u307E\u3059\u3002\u30D5\u30A3\u30FC\u30C9\u30D0\u30C3\u30AF\u3092\u9001\u4FE1\u3057\u307E\u3057\u305F\u3002" : "\u30DE\u30FC\u30AF\u3092\u4FDD\u5B58\u3057\u307E\u3057\u305F\u3002";
|
|
429
|
+
} catch (error) {
|
|
430
|
+
console.warn("markable: unable to persist annotation", error, annotation);
|
|
431
|
+
status.textContent = "\u30ED\u30FC\u30AB\u30EB\u306B\u8A18\u9332\u3057\u307E\u3057\u305F\u3002\u6C38\u7D9A\u5316\u3059\u308B\u306B\u306F\u30A8\u30F3\u30C9\u30DD\u30A4\u30F3\u30C8\u3092\u8A2D\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044\u3002";
|
|
432
|
+
}
|
|
433
|
+
annotations.push(annotation);
|
|
434
|
+
panel.reset();
|
|
435
|
+
renderList();
|
|
436
|
+
closePanel();
|
|
437
|
+
});
|
|
438
|
+
`;
|
|
439
|
+
}
|
|
440
|
+
export {
|
|
441
|
+
markable
|
|
442
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@f12o/markable",
|
|
3
|
+
"version": "2026.6.0",
|
|
4
|
+
"description": "Headless interaction layer for marking artifacts with structured feedback and rewrite annotations.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/f4ah6o/markable.git"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"./core": {
|
|
23
|
+
"types": "./dist/core.d.ts",
|
|
24
|
+
"import": "./dist/core.js"
|
|
25
|
+
},
|
|
26
|
+
"./dom": {
|
|
27
|
+
"types": "./dist/dom.d.ts",
|
|
28
|
+
"import": "./dist/dom.js"
|
|
29
|
+
},
|
|
30
|
+
"./vite": {
|
|
31
|
+
"types": "./dist/vite.d.ts",
|
|
32
|
+
"import": "./dist/vite.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "^22.10.2",
|
|
37
|
+
"tsup": "^8.3.5",
|
|
38
|
+
"typescript": "^5.7.2",
|
|
39
|
+
"vite": "^6.0.3",
|
|
40
|
+
"vite-plus": "^0.1.24",
|
|
41
|
+
"vitest": "^4.1.9"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"vite": ">=5"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"vite": {
|
|
48
|
+
"optional": true
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsup src/index.ts src/core.ts src/dom.ts src/vite.ts --format esm --dts",
|
|
53
|
+
"typecheck": "tsc --noEmit",
|
|
54
|
+
"build:demo": "pnpm --filter @f12o/markable-vite-todo-demo build",
|
|
55
|
+
"build:shadcn-admin": "pnpm --filter @f12o/markable-shadcn-admin-demo build"
|
|
56
|
+
}
|
|
57
|
+
}
|