2023/04/28
- 拡大縮小対応で引き続き試行錯誤
- 縮小したとき画面に隙間(空間)が出来てしまう問題を解決するために、またtransform.scaleを使う形に戻してみた。
- すると縮小はなんとかなったが、今度はスクロールがイマイチになってしまった。flutterはウィジェットごとにクセがあり、思い通りに動かすのは難しい。。。
- 結局スクロールはInteractiveViewerを外して自分で計算することにした。加速度処理が無くなって快適さはなくなったが、「上にドラッグ開始すると斜めにパンできなくなる」など謎のScrollコンフリクト等もなくなり、とても制御しやすくなった。
- ソース自体もToolbar部とCanvas部を明確に分けて、概念的に把握しやすくした。
この時点でのflutterソースファイル:main.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'logic_collection.dart';
import 'dart:math';
import 'dart:ui';
import 'dart:convert'; //JSON形式のデータを扱う
import 'package:path/path.dart' as path_dart; //asが無いとcontextを上書きされてしまう
import 'package:file_selector/file_selector.dart';
class MyCustomScrollBehavior extends MaterialScrollBehavior {
@override
Set<PointerDeviceKind> get dragDevices => {
PointerDeviceKind.touch, // 通常のタッチ入力デバイス
PointerDeviceKind.mouse, // これを追加!
};
}
void main() => runApp(const ProviderScope(child: MyApp()));
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
scrollBehavior: MyCustomScrollBehavior(),
home: const Scaffold(
body: SafeArea(child:
WholeApp(),
),
),
);
}
}
class WholeApp extends StatefulWidget {
const WholeApp({Key? key}) : super(key: key);
@override
State<WholeApp> createState() => _WholeAppState();
}
class _WholeAppState extends State<WholeApp> {
GlobalKey<CanvasZoneState> canvasZoneKey = GlobalKey();
GlobalKey<ToolbarZoneState> toolbarZoneKey = GlobalKey();
late ToolbarZone toolbarZone;
late CanvasZone canvasZone;
double x = 0.0;
double y = 0.0;
@override
void initState() {
canvasZone = CanvasZone(key: canvasZoneKey);
toolbarZone = ToolbarZone(key: toolbarZoneKey);
super.initState();
}
void _updateMouseLocation(PointerEvent details) {
setState(() {
toolbarZoneKey.currentState!.mouseX = details.position.dx;
toolbarZoneKey.currentState!.mouseY = details.position.dy;
});
}
@override
Widget build(BuildContext context) {
return WholeAppInheritedWidget(
wholeAppState: this,
child: MouseRegion(
// onHover: _updateMouseLocation, //座標を表示する
child: Column(
children: [
toolbarZone,
const Divider(),
canvasZone,
]
),
),
);
}
}
class WholeAppInheritedWidget extends InheritedWidget {
final _WholeAppState wholeAppState;
const WholeAppInheritedWidget({required this.wholeAppState, required Widget child, super.key}) : super(child: child);
@override
bool updateShouldNotify(WholeAppInheritedWidget oldWidget) => true;
static _WholeAppState of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<WholeAppInheritedWidget>()!.wholeAppState;
}
}
class ToolbarZone extends ConsumerStatefulWidget {
@override
final GlobalKey<ToolbarZoneState> key;
const ToolbarZone({required this.key}) : super(key: key);
@override
ToolbarZoneState createState() => ToolbarZoneState();
}
class ToolbarZoneState extends ConsumerState<ToolbarZone> {
double mouseX = -1;
double mouseY = -1;
@override
void initState() {
super.initState();
}
void addNewLane() => WholeAppInheritedWidget.of(context).canvasZoneKey.currentState!.addNewLane();
void removeLane() => WholeAppInheritedWidget.of(context).canvasZoneKey.currentState?.removeLane();
void test() => WholeAppInheritedWidget.of(context).canvasZoneKey.currentState?.test();
void scaleUp() {
/*
if (WholeAppInheritedWidget.of(context).canvasZoneKey.currentState == null) return;
TransformationController controller = WholeAppInheritedWidget.of(context).canvasZoneKey.currentState!._transformationController;
controller.value = Matrix4.identity()..scale(controller.value.getMaxScaleOnAxis() * 1.2);
ref.watch(currentScale.notifier).state = controller.value.getMaxScaleOnAxis();
*/
ref.watch(currentScale.notifier).state = ref.read(currentScale)*1.2;
}
void scaleDown() {
/*
if (canvasZoneKey.currentState == null) return;
TransformationController controller = canvasZoneKey.currentState!._transformationController;
controller.value = Matrix4.identity()..scale(controller.value.getMaxScaleOnAxis() * 0.85);
ref.watch(currentScale.notifier).state = controller.value.getMaxScaleOnAxis();
print("${MediaQuery.of(context).size.height * (1 / ref.watch(currentScale))}");
*/
ref.watch(currentScale.notifier).state = ref.read(currentScale)*0.85;
}
void scaleReset() {
if (WholeAppInheritedWidget.of(context).canvasZoneKey.currentState == null) return;
TransformationController controller = WholeAppInheritedWidget.of(context).canvasZoneKey.currentState!._transformationController;
controller.value = Matrix4.identity();
ref.watch(currentScale.notifier).state = 1.0;
}
Future<void> loadDataWithDialog() async {
const XTypeGroup typeGroup = XTypeGroup(
label: 'json',
extensions: ['json'],
);
final XFile? file = await openFile(acceptedTypeGroups: [typeGroup]);
if (file != null) {
await loadData(fileName: file.path, state: WholeAppInheritedWidget.of(context).canvasZoneKey.currentState);
//レーンの高さを取得
ref.watch(lanesBodyCanvasHeight.notifier).state = WholeAppInheritedWidget.of(context).canvasZoneKey.currentState!.getMaxLanesHight();
//スクロールポジションを戻す
WholeAppInheritedWidget.of(context).canvasZoneKey.currentState!._scrollController.jumpTo(0);
WholeAppInheritedWidget.of(context).canvasZoneKey.currentState!.setState(() {}); //画面の再描画
}
}
Future<void> saveAs() async {
String? path = await getSavePath(
acceptedTypeGroups: [
const XTypeGroup(label: 'json', extensions: ['json'])
],
suggestedName: "",
);
if (path != null) {
if (path_dart.extension(path) == '') {
path = '$path.json';
}
saveData(fileName: path, state: WholeAppInheritedWidget.of(context).canvasZoneKey.currentState);
}
}
String getCurrentMousePositionStr() {
String result = "";
if (mouseX != -1) {
result = "x:${mouseX.toInt().toString().padLeft(5,'0')}, y:${mouseY.toInt().toString().padLeft(5,'0')} ";
}
return result;
}
@override
Widget build(BuildContext context) {
return ToolbarZoneInheritedWidget(
toolbarZoneState: this,
child: Column(children: [
Align(
alignment: Alignment.center,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Container( //ツールバー
margin: const EdgeInsets.only(top: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: addNewLane,
child: const Text("レーン追加"),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: removeLane,
child: const Text("削除"),
),
const SizedBox(width: 30),
ElevatedButton(
onPressed: scaleUp,
child: const Text("拡大"),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: scaleDown,
child: const Text("縮小"),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: scaleReset,
child: const Text("リセット"),
),
const SizedBox(width: 30),
ElevatedButton(
onPressed: saveAs,
child: const Text("名前を付けて保存"),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: loadDataWithDialog,
child: const Text("ファイルを読込"),
),
const SizedBox(width: 30),
ElevatedButton(
onPressed: test,
child: const Text("テスト"),
),
const SizedBox(width: 30),
Text(getCurrentMousePositionStr()),
],
),
),
),
),
]),
);
}
}
class ToolbarZoneInheritedWidget extends InheritedWidget {
final ToolbarZoneState toolbarZoneState;
const ToolbarZoneInheritedWidget({required this.toolbarZoneState, required Widget child, super.key}) : super(child: child);
@override
bool updateShouldNotify(ToolbarZoneInheritedWidget oldWidget) => true;
static ToolbarZoneState of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<ToolbarZoneInheritedWidget>()!.toolbarZoneState;
}
}
class CanvasZone extends ConsumerStatefulWidget {
@override
final GlobalKey<CanvasZoneState> key;
const CanvasZone({required this.key}) : super(key: key);
@override
CanvasZoneState createState() => CanvasZoneState();
}
class CanvasZoneState extends ConsumerState<CanvasZone> {
double widthA = double.infinity;
List<LaneBody> lanes = [];
List<LaneHeader> laneHeaders = [];
final TransformationController _transformationController = TransformationController();
final GlobalKey _interactiveViewerKey = GlobalKey();
double globalTop = 0.0;
final _scrollController = ScrollController();
// double _scale = 1.0;
double laneWidth = 100;
@override
void initState() {
super.initState();
addNewLane();
loadDataAtStartUp();
getMaxLanesHight();
WidgetsBinding.instance.addPostFrameCallback((_) {
//CanvasのTop座標を取得(スクロール制御用)
globalTop = (widget.key.currentContext?.findRenderObject() as RenderBox).localToGlobal(Offset.zero).dy;
//レーンの幅を決定
ref.watch(eachLaneWidth.notifier).state = MediaQuery.of(context).size.width * 0.4;
laneWidth = MediaQuery.of(context).size.width * 0.4;
lanes[0].ideas[0].key.currentState?._textFocusNode.requestFocus();
});
}
void test() {
setState(() {});
}
double getMaxLanesHight() {
int maxIdeaLength = 1;
for (var lane in lanes) {
if (lane.ideas.length > maxIdeaLength ) {
maxIdeaLength = lane.ideas.length;
}
}
return maxIdeaLength * ref.read(eachIdeaHeight);
}
Future<void> loadDataAtStartUp() async {
await loadData(state: this);
//レーンの高さを取得
ref.watch(lanesBodyCanvasHeight.notifier).state = getMaxLanesHight();
}
Future<void> addNewLane() async {
setState(() {
var index = lanes.length;
var newLane = LaneBody(index: index, ideas: [Idea(index: 0)]);
lanes.add(newLane);
newLane.ideas[0].parentLane = newLane;
var newLaneHeader = LaneHeader(index: index, relatedLane: newLane);
laneHeaders.add(newLaneHeader);
});
}
Future<void> addNewLaneAndFocusTitle(int targetLaneIndex) async {
if (targetLaneIndex == lanes.length) {
await addNewLane();
}
// ウィジェットが構築されるのを待つ
WidgetsBinding.instance.addPostFrameCallback((_) {
laneHeaders[targetLaneIndex].key.currentState?._titleFocusNode.requestFocus();
});
}
Future<void> addNewLaneAndFocusIt() async {
await addNewLane();
WidgetsBinding.instance.addPostFrameCallback((_) {
lanes[lanes.length - 1].ideas[0].key.currentState?._textFocusNode.requestFocus();
});
}
void removeLane() {
setState(() {
if (lanes.length > 1) {
//関係するリレーションを削除
for (var idea in lanes[lanes.length - 1].ideas) {
lanes[lanes.length - 1].key.currentState?.removeFromRelation(idea);
}
lanes.removeLast();
laneHeaders.removeLast();
}
});
}
void focusNextLane(int targetLaneIndex, int targetIdeaIndex) {
if(targetLaneIndex < 0 || targetIdeaIndex < 0) return;
if( checkAndFillIdeas(targetLaneIndex, targetIdeaIndex) ) {
WidgetsBinding.instance.addPostFrameCallback((_) {
lanes[targetLaneIndex].ideas[targetIdeaIndex].key.currentState?._textFocusNode.requestFocus(); //FillIdeasされた場合は遅延でのフォーカスが必要
});
} else {
lanes[targetLaneIndex].ideas[targetIdeaIndex].key.currentState?._textFocusNode.requestFocus(); //FillIdeasされなかった場合は即座にフォーカス
}
}
bool checkAndFillIdeas(int targetLaneIndex, int targetIdeaIndex) {
bool added = false; //アイディアかレーンが追加されたかどうかで、呼び出し元が処理を分けられるようにする
if (targetLaneIndex < 0 || targetIdeaIndex < 0) return added;
if (targetLaneIndex == lanes.length) {
addNewLane();
added = true;
}
if (targetLaneIndex < lanes.length) {
for (int i = lanes[targetLaneIndex].ideas.length - 1; i < targetIdeaIndex; i++) {
if (lanes[targetLaneIndex].key.currentState == null) {
setState(() {
//addNewIdeaが使えない状態なので、やむなく自力実装
lanes[targetLaneIndex].ideas.add(Idea(index: i + 1));
lanes[targetLaneIndex].ideas[lanes[targetLaneIndex].ideas.length - 1].parentLane = lanes[targetLaneIndex];
for (int j = 0; j < lanes[targetLaneIndex].ideas.length; j++) {
lanes[targetLaneIndex].ideas[j].index = j;
}
});
} else {
lanes[targetLaneIndex].key.currentState?.addNewIdea(i);
}
added = true;
}
}
return added;
}
@override
Widget build(BuildContext context) {
return CanvasZoneInheritedWidget(
canvasZoneState: this,
child: Expanded( //これが無いと画面描画のOverFlowを起こす
child: Stack(
children:[
Positioned(
top: 0,
left:0,
child: Transform.scale(
alignment: Alignment.topLeft,
scale: ref.watch(currentScale),
child: Container(
//Canvasのサイズ
width: max( ref.watch(eachLaneWidth) * lanes.length, MediaQuery.of(context).size.width * (1 / ref.watch(currentScale)) ),
height: ( globalTop + ref.watch(headerHeight) + ref.watch(lanesBodyCanvasHeight) ) * (1 / ref.watch(currentScale)),
color: Colors.transparent, // カラーを指定しないと上のGestureDetectorの範囲がchildの範囲に収まってしまう
child: Stack(
children: [
Positioned( // タイトル行
top:0,
left:0,
child: SizedBox(
width: max( ref.watch(eachLaneWidth) * lanes.length, MediaQuery.of(context).size.width * (1 / ref.watch(currentScale)) ),
height: 50,
child: Stack(
children: [
Positioned(
top: 0,
left: ref.watch(canvasScrollDeltaX),
width: ref.watch(eachLaneWidth) * lanes.length,
height: ref.watch(headerHeight),
child: Row(
children: laneHeaders,
),
),
],
),
),
),
Positioned( // ボディ部
top: ref.watch(headerHeight),
left:0,
child: Container(
width: MediaQuery.of(context).size.width * (1 / ref.watch(currentScale)),
height: ( globalTop + ref.watch(headerHeight) + ref.watch(lanesBodyCanvasHeight) ) * (1 / ref.watch(currentScale)),
child: Stack(
children: [
Positioned(
top: ref.watch(canvasScrollDeltaY),
left:0,
child: Scrollbar(
controller: _scrollController,
thumbVisibility: true,
trackVisibility: true,
child: SingleChildScrollView(
primary: false, // フォーカスを Scrollbarへ
controller: _scrollController,
child: SizedBox(
width: max( ref.watch(eachLaneWidth) * lanes.length, MediaQuery.of(context).size.width * (1 / ref.watch(currentScale)) ),
height: ( globalTop + ref.watch(headerHeight) + ref.watch(lanesBodyCanvasHeight) ) * (1 / ref.watch(currentScale)),
child: GestureDetector(
onPanUpdate: (details) { //タイトル行を一緒に動かす
setState(() {
//横方向のスクロール範囲:全レーンの幅から、キャンバス領域の描画サイズを引いたもの + バッファ
double limitX = ref.watch(eachLaneWidth) * lanes.length - MediaQuery.of(context).size.width + (ref.watch(eachLaneWidth)*1/3);
ref.watch(canvasScrollDeltaX.notifier).state += details.delta.dx;
ref.watch(canvasScrollDeltaX.notifier).state = ref.watch(canvasScrollDeltaX).clamp( -limitX, 0);
//縦方向のスクロール範囲:lanesBodyの高さから、キャンバス領域の描画サイズを引いたもの + バッファ
double limitY = ref.watch(lanesBodyCanvasHeight) - ( MediaQuery.of(context).size.height - globalTop - ref.watch(headerHeight)) + (ref.watch(eachIdeaHeight)*2/3);
ref.watch(canvasScrollDeltaY.notifier).state += details.delta.dy;
ref.watch(canvasScrollDeltaY.notifier).state = ref.watch(canvasScrollDeltaY).clamp( -limitY, 0);
print("ref.watch(canvasScrollDeltaY):${ref.read(canvasScrollDeltaY)} limitY:${limitY}");
//_scrollController.jumpTo(targetY.clamp(0, yRange)); //スクロールのバウンス用
});
},
onTap: () async {
if (ref.watch(relationHoverFlg)) {
await relationSelectDialog(context,this);
setState(() {});
}
},
child:Stack(
children:
buildIdeasAndRelations(this),
),
),
),
),
),
),
]
),
),
),
]
),
),
),
),
]
),
),
);
}
}
class CanvasZoneInheritedWidget extends InheritedWidget {
final CanvasZoneState canvasZoneState;
const CanvasZoneInheritedWidget({required this.canvasZoneState, required Widget child, super.key}) : super(child: child);
@override
bool updateShouldNotify(CanvasZoneInheritedWidget oldWidget) => true;
static CanvasZoneState of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<CanvasZoneInheritedWidget>()!.canvasZoneState;
}
}
class IdeasRelation {
Idea fromIdea;
Idea toIdea;
RelationShape fromShape=RelationShape.dot;
RelationShape toShape=RelationShape.dot;
String note = '';
IdeasRelation({required this.fromIdea, required this.toIdea});
}
class LaneHeader extends ConsumerStatefulWidget {
@override
final GlobalKey<LaneHeaderState> key = GlobalKey<LaneHeaderState>();
int index;
String title;
LaneBody relatedLane;
final double indentWidth = 40; //インデントの下げ幅
LaneHeader({
required this.index,
required this.relatedLane,
this.title = '',
}) : super(key: ValueKey(GlobalKey<LaneHeaderState>().toString()));
@override
ConsumerState<LaneHeader> createState() => LaneHeaderState();
}
class LaneHeaderState extends ConsumerState<LaneHeader> {
final TextEditingController titleController = TextEditingController();
final FocusNode _titleFocusNode = FocusNode();
@override
void initState() {
super.initState();
titleController.text = widget.title;
}
@override
Widget build(BuildContext context) {
return LaneHeaderInheritedWidget(
laneHeaderState: this,
child: SizedBox(
height: 50,
width: ref.watch(eachLaneWidth),
child: Focus(
onKey: (FocusNode node, RawKeyEvent event) {
if (event.runtimeType == RawKeyDownEvent) {
if (event.logicalKey == LogicalKeyboardKey.arrowDown) { //カーソルキー ↓
widget.relatedLane.ideas[0].key.currentState?._textFocusNode.requestFocus();
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight && //カーソルキー →
titleController.selection.baseOffset == titleController.text.length) {
CanvasZoneInheritedWidget.of(context).addNewLaneAndFocusTitle(widget.index + 1);
} else if (event.logicalKey == LogicalKeyboardKey.arrowLeft && //カーソルキー ←
titleController.selection.baseOffset == 0) {
if (widget.index > 0) {
CanvasZoneInheritedWidget.of(context).laneHeaders[widget.index - 1].key.currentState?._titleFocusNode.requestFocus();
}
}
}
return KeyEventResult.ignored;
},
child: Container( //タイトル
color: Colors.blue[50],
padding: const EdgeInsets.symmetric(horizontal: indentWidth),
child: TextField(
autofocus: true,
controller: titleController,
focusNode: _titleFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
disabledBorder: const OutlineInputBorder(
borderSide: BorderSide(color: Colors.blue, width: 2.0),
),
fillColor: Colors.blue[50],
filled: true,
),
style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold, color: Colors.blue[900]),
keyboardType: TextInputType.text,
textInputAction: TextInputAction.done,
onSubmitted: (value) {
_titleFocusNode.unfocus();
},
),
)),
),
);
}
}
class LaneHeaderInheritedWidget extends InheritedWidget {
final LaneHeaderState laneHeaderState;
const LaneHeaderInheritedWidget({required this.laneHeaderState, required Widget child, super.key}) : super(child: child);
@override
bool updateShouldNotify(LaneHeaderInheritedWidget oldWidget) => true;
static LaneHeaderState of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<LaneHeaderInheritedWidget>()!.laneHeaderState;
}
}
class LaneBody extends ConsumerStatefulWidget {
@override
final GlobalKey<LaneBodyState> key = GlobalKey<LaneBodyState>();
int index;
String title;
List<Idea> ideas;
LaneBody({
required this.index,
required this.ideas,
this.title = '',
}) : super(key: ValueKey(GlobalKey<LaneBodyState>().toString()));
@override
ConsumerState<LaneBody> createState() => LaneBodyState();
}
class LaneBodyState extends ConsumerState<LaneBody> {
List<Idea> get ideas => widget.ideas;
final TextEditingController titleController = TextEditingController();
void addNewIdea(int currentIndex) {
setState(() {
int currentIndentLevel = ideas[currentIndex].indentLevel;
ideas.insert(
currentIndex + 1,
Idea(
index: currentIndex + 1,
parentLane: widget,
textFieldHeight: 48.0,
indentLevel: currentIndentLevel,
),
);
rebuildItemIndexes(currentIndex + 2);
});
}
void addNewIdeaWithFocus(int currentIndex) {
addNewIdea(currentIndex);
WidgetsBinding.instance.addPostFrameCallback((_) {
ideas[currentIndex + 1].key.currentState?._textFocusNode.requestFocus();
});
}
void rebuildItemIndexes(int startIndex) {
for (int i = startIndex; i < ideas.length; i++) {
ideas[i].index = i;
}
ref.watch(lanesBodyCanvasHeight.notifier).state = CanvasZoneInheritedWidget.of(context).getMaxLanesHight();
}
void moveIdea(int currentIdeaIndex, int direction) {
if (currentIdeaIndex + direction >= 0 && currentIdeaIndex + direction < ideas.length) {
setState(() {
Idea temp = ideas[currentIdeaIndex];
ideas[currentIdeaIndex] = ideas[currentIdeaIndex + direction];
ideas[currentIdeaIndex + direction] = temp;
ideas[currentIdeaIndex].key.currentState?.updateIndex(currentIdeaIndex);
ideas[currentIdeaIndex + direction].key.currentState?.updateIndex(currentIdeaIndex + direction);
});
}
}
void moveAndInsertIdeaToAnotherLane(int currentIdeaIndex, int direction) {
if(widget.index + direction < 0) {
return;
}
var canvas = CanvasZoneInheritedWidget.of(context);
canvas.checkAndFillIdeas(widget.index + direction, max(currentIdeaIndex - 1,0) );
canvas.lanes[widget.index + direction].ideas.insert(currentIdeaIndex, ideas[currentIdeaIndex]);
canvas.lanes[widget.index + direction].ideas[currentIdeaIndex].parentLane = CanvasZoneInheritedWidget.of(context).lanes[widget.index + direction];
//rebuildItemIndexes(0)を行いたいが、stateが出来ていないため手動実装
for (int i = 0; i < canvas.lanes[widget.index + direction].ideas.length; i++) {
canvas.lanes[widget.index + direction].ideas[i].index = i;
}
canvas.lanes[widget.index].ideas.removeAt(currentIdeaIndex);
rebuildItemIndexes(0);
ref.watch(lanesBodyCanvasHeight.notifier).state = CanvasZoneInheritedWidget.of(context).getMaxLanesHight();
canvas.setState(() {}); //画面の再描画
}
void removeFromRelation(Idea targetIdea) {
//削除対象のリレーションを探す
List<IdeasRelation> removeTargetList = [];
if (ref.read(ideasRelationList).isNotEmpty) {
for (var relationSet in ref.read(ideasRelationList)) {
if (relationSet.fromIdea == targetIdea || relationSet.toIdea == targetIdea) {
removeTargetList.add(relationSet);
}
}
}
//実際に削除する
if (removeTargetList.isNotEmpty) {
for (var relationSet in removeTargetList) {
removeRelation(ref.watch(ideasRelationList.notifier).state, relationSet);
}
}
}
void removeIdea(int currentIndex) {
if (ideas.length > 1) {
removeFromRelation(ideas[currentIndex]);
ideas.removeAt(currentIndex);
for (int i = currentIndex; i < ideas.length; i++) {
ideas[i].index = i;
}
ideas[max(0, currentIndex - 1)].key.currentState?._textFocusNode.requestFocus();
CanvasZoneInheritedWidget.of(context).setState(() {}); //画面の再描画
}
}
void focusNextIdea(int currentIndex, int direction) {
if (currentIndex == 0 && direction <= 0) { //タイトルにフォーカスを移す
CanvasZoneInheritedWidget.of(context).laneHeaders[widget.index].key.currentState!._titleFocusNode.requestFocus();
} else if (currentIndex + direction >= 0 && currentIndex + direction < ideas.length) {
ideas[currentIndex + direction].key.currentState?._textFocusNode.requestFocus();
}
}
void redrawAffectedIdeas(int startIndex, int endIndex) {
if (startIndex < 0 || endIndex >= ideas.length) return;
setState(() {
for (int i = startIndex; i <= endIndex; i++) {
//再描画を促す
//ideas[i].key.currentState?.indentLevel = widget.key.currentState?.indentLevel ?? indentLevel;
}
});
}
@override
void initState() {
super.initState();
titleController.text = widget.title;
}
@override
Widget build(BuildContext context) {
return LaneBodyInheritedWidget(
laneState: this,
child: SizedBox(
width: double.infinity,
height: (ref.watch(eachIdeaHeight) * ideas.length).toDouble(),
child: Column(
children: ideas
),
),
);
}
}
class LaneBodyInheritedWidget extends InheritedWidget {
final LaneBodyState laneState;
const LaneBodyInheritedWidget({required this.laneState, required Widget child, super.key}) : super(child: child);
@override
bool updateShouldNotify(LaneBodyInheritedWidget oldWidget) => true;
static LaneBodyState of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<LaneBodyInheritedWidget>()!.laneState;
}
}
class Idea extends ConsumerStatefulWidget {
@override
final GlobalKey<IdeaState> key = GlobalKey<IdeaState>();
int index;
int indentLevel;
final double textFieldHeight; //テキスト欄の高さ
String content;
LaneBody? parentLane;
List<Idea> relatedIdeasTo = [];
List<Idea> relatedIdeasFrom = [];
Idea({
required this.index,
this.parentLane,
this.textFieldHeight = 48.0, //デフォルトは48
this.indentLevel = 0,
this.content = '',
}) : super(key: ValueKey(GlobalKey<IdeaState>().toString()));
@override
IdeaState createState() => IdeaState();
}
class IdeaState extends ConsumerState<Idea> with AutomaticKeepAliveClientMixin<Idea> {
final TextEditingController textController = TextEditingController();
final FocusNode _textFocusNode = FocusNode();
void updateIndex(int newIndex) {
setState(() {
widget.index = newIndex;
});
}
// インデントの線を描画する
void _indentLinePaint(Canvas canvas, Size size) {
indentLinePaint(canvas, size, context, widget.index, widget.indentLevel, widget.textFieldHeight, indentWidth);
}
String toJson() {
Map<String, dynamic> data = {
'index': widget.index,
'indentLevel': widget.indentLevel,
'text': textController.text,
};
return json.encode(data);
}
void startRelationMode() {
ref.watch(relatedIdeaSelectMode.notifier).state = true;
ref.watch(originIdeaForSelectMode.notifier).state = widget;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( content: Text( '接続先を選んでEnterキーを押してください'),)
);
}
void endRelationMode() {
StateController<bool> mode = ref.watch(relatedIdeaSelectMode.notifier);
Idea? origin = ref.watch(originIdeaForSelectMode.notifier).state;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
if (origin != null) {
bool cancelFlg = false;
List<IdeasRelation> ideasRelations = ref.watch(ideasRelationList.notifier).state;
//自分自身に接続しようとしていないかチェック
if(origin == widget) {
cancelFlg = true;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( content: Text('自分自身には接続できません'), ),
);
} else {
//既に同じリレーションが無いかチェック
for (var element in ideasRelations) {
if ( (element.fromIdea == origin && element.toIdea == widget) || (element.fromIdea == widget && element.toIdea == origin) ){
cancelFlg = true;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( content: Text('既に接続されています'), ),
);
break;
}
}
}
if(cancelFlg == false) {
widget.relatedIdeasFrom.add(origin);
origin.relatedIdeasTo.add(widget);
ref.watch(ideasRelationList.notifier).state.add(IdeasRelation(fromIdea: origin, toIdea: widget));
CanvasZoneInheritedWidget.of(context).setState(() {}); //Relationの線を描画
}
origin = null;
}
mode.state = false;
}
late final Map<Type, Action<Intent>> _actionMap;
@override
void initState() {
super.initState();
textController.text = widget.content;
_actionMap = <Type, Action<Intent>>{
ActivateIntent: CallbackAction<Intent>(
onInvoke: (Intent intent) => startRelationMode(),
),
};
}
@override
bool get wantKeepAlive => true; //これが無いと画面外から出たときに中身も消えてしまう
final Map<ShortcutActivator, Intent> _shortcutMap =
const <ShortcutActivator, Intent>{
SingleActivator(LogicalKeyboardKey.keyX, control: true): ActivateIntent(),
};
//TextFieldでEnterを押した場合のキーの処理について
//・webだと、submit→key webの順で処理される
//・windowsだと、key → submitの順で処理される
@override
Widget build(BuildContext context) {
super.build(context);
return Focus(
onKey: (FocusNode node, RawKeyEvent event) {
//print("event.runtimeType:${event.runtimeType.toString()} Normal${event.logicalKey.keyLabel} , Web${ (event.data is RawKeyEventDataWeb) ? (event.data as RawKeyEventDataWeb).code : ''}");
if (event.runtimeType == RawKeyDownEvent) {
if ((event.logicalKey.keyLabel == 'Enter') ||
( (event.data is RawKeyEventDataWeb)&&(event.data as RawKeyEventDataWeb).code == 'Enter') ) { //Enterの場合のみ特殊処理が必要
if (event.isControlPressed) {
startRelationMode();
}
else {
if (ref.read(relatedIdeaSelectMode.notifier).state == true) { //relatedの指定モード
endRelationMode();
_textFocusNode.requestFocus();
} else {
_textFocusNode.unfocus();
LaneBodyInheritedWidget.of(context).addNewIdeaWithFocus(widget.index);
}
}
return KeyEventResult.handled;
} else if (event.logicalKey == LogicalKeyboardKey.capsLock) {
// CapsLockキーを無視する条件を追加
return KeyEventResult.ignored;
} else if (event.isControlPressed) { // コントロールキー押下
bool needRepaint = false;
if (event.logicalKey == LogicalKeyboardKey.arrowUp) { // Ctrl + ↑
LaneBodyInheritedWidget.of(context).moveIdea(widget.index, -1);
needRepaint = true;
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { // Ctrl + ↓
if (widget.index + 1 == LaneBodyInheritedWidget.of(context).ideas.length) {
LaneBodyInheritedWidget.of(context).addNewIdea(widget.index);
LaneBodyInheritedWidget.of(context).moveIdea(widget.index, 1);
LaneBodyInheritedWidget.of(context).rebuildItemIndexes(0); //再度構築しなおさないと、インデックスがおかしくなる
} else {
LaneBodyInheritedWidget.of(context).moveIdea(widget.index, 1);
}
needRepaint = true;
}
if (event.logicalKey == LogicalKeyboardKey.arrowRight) { // Ctrl + →
LaneBodyInheritedWidget.of(context).moveAndInsertIdeaToAnotherLane(widget.index, 1);
needRepaint = false;
} else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { // Ctrl + ←
LaneBodyInheritedWidget.of(context).moveAndInsertIdeaToAnotherLane(widget.index, -1);
needRepaint = false;
} else if (event.logicalKey == LogicalKeyboardKey.tab) { // Ctrl + TAB
startRelationMode();
}
if (needRepaint) {
findAndRepaintAffectedIdeas(context, widget.index, widget.indentLevel, widget.index,
widget.index + 1 /*Ctrlキーの場合、1つ下からチェックが必要*/);
}
} else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { //カーソルキー ↑
LaneBodyInheritedWidget.of(context).focusNextIdea(widget.index, -1);
return KeyEventResult.handled; //余計なフォーカスの移動を防ぐ
} else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { //カーソルキー ↓
if (widget.index == LaneBodyInheritedWidget.of(context).ideas.length - 1) {
LaneBodyInheritedWidget.of(context).addNewIdeaWithFocus(widget.index);
} else {
LaneBodyInheritedWidget.of(context).focusNextIdea(widget.index, 1);
}
return KeyEventResult.handled; //余計なフォーカスの移動を防ぐ
} else if (event.logicalKey == LogicalKeyboardKey.arrowRight && //カーソルキー →
textController.selection.baseOffset == textController.text.length) {
int laneIndex =
CanvasZoneInheritedWidget.of(context).lanes.indexOf(LaneBodyInheritedWidget.of(context).widget);
CanvasZoneInheritedWidget.of(context).focusNextLane(laneIndex + 1, widget.index);
} else if (event.logicalKey == LogicalKeyboardKey.arrowLeft && textController.selection.baseOffset == 0) {
int laneIndex =
CanvasZoneInheritedWidget.of(context).lanes.indexOf(LaneBodyInheritedWidget.of(context).widget);
CanvasZoneInheritedWidget.of(context).focusNextLane(laneIndex - 1, widget.index);
} else if (event.logicalKey == LogicalKeyboardKey.tab) { //TABキー
if (event.isShiftPressed) { //SHIFT + TABキー
setState(() {
widget.indentLevel = max(0, widget.indentLevel - 1);
});
} else {
setState(() {
widget.indentLevel += 1;
});
}
findAndRepaintAffectedIdeas(context, widget.index, widget.indentLevel, widget.index, widget.index);
return KeyEventResult.handled; //デフォルトのタブの挙動を無効化して、フォーカスの移動を防ぐ
} else if ((event.logicalKey == LogicalKeyboardKey.delete || //DELキー & BSキー
event.logicalKey == LogicalKeyboardKey.backspace) &&
textController.text.isEmpty) {
LaneBodyInheritedWidget.of(context).removeIdea(widget.index);
} else if (event.logicalKey == LogicalKeyboardKey.f1) { // F1キー
startRelationMode();
}
}
return KeyEventResult.ignored;
},
child: RawKeyboardListener(
focusNode: FocusNode(),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: ideaHorizontalMargin, vertical: ideaVerticalMargin),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(width: ideaLeftSpace, child: DecoratedBox(decoration: BoxDecoration(color: Colors.red))),
Expanded(
flex: 4,
child: Row(children: [
CustomPaint(
painter: _CustomPainter(_indentLinePaint),
child: Container(width: indentWidth * widget.indentLevel),
),
Expanded(
child: SizedBox(
height: widget.textFieldHeight,
child: FocusableActionDetector(
actions: _actionMap,
shortcuts: _shortcutMap,
child: TextFormField(
controller: textController,
focusNode: _textFocusNode,
maxLines: 1,
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.done,
onFieldSubmitted: (value) {
_textFocusNode.requestFocus(); //Enterはこれが無いとKeyEventが発生しない(おそらくフォーカスが無くなるから。少なくともWebでは必須)
},
),
),
),
),
]),
),
],
),
),
)
);
}
}
class _CustomPainter extends CustomPainter {
final void Function(Canvas, Size) _paint;
_CustomPainter(this._paint);
@override
void paint(Canvas canvas, Size size) {
_paint(canvas, size);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
logic_collection.dart (main.dartと同じフォルダに配置)
import 'package:flutter/material.dart';
import 'main.dart';
import 'dart:convert'; //JSON形式のデータを扱う
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import 'dart:math';
import 'package:flutter_riverpod/flutter_riverpod.dart';
//リレーションの情報
StateProvider<bool> relatedIdeaSelectMode = StateProvider((ref) => false);
StateProvider<Idea?> originIdeaForSelectMode = StateProvider((ref) => null);
StateProvider<List<IdeasRelation>> ideasRelationList = StateProvider((ref) => []);
StateProvider<IdeasRelation?> selectedRelation = StateProvider((ref) => null);
//リレーションの見た目
enum RelationShape{dot,arrow}
StateProvider<Color> relationColor = StateProvider((ref) => Colors.blue);
StateProvider<bool> relationHoverFlg = StateProvider((ref) => false);
const bool relationDebugPaint = false;
//スケール
StateProvider<double> currentScale = StateProvider((ref) => 1);
//各種ウィジェットのサイズと表現
StateProvider<double> headerHeight = StateProvider((ref) => 50);
StateProvider<double> eachLaneWidth = StateProvider((ref) => 500);
StateProvider<double> eachIdeaHeight = StateProvider((ref) => 64); //48+8+8
StateProvider<double> lanesBodyCanvasHeight = StateProvider((ref) => 500);
StateProvider<double> relationDotSize = StateProvider((ref) => 6);
StateProvider<double> relationArrowSize = StateProvider((ref) => 10);
const double ideaLeftSpace = 26;
const double ideaHorizontalMargin = 16;
const double ideaVerticalMargin = 8;
const double indentWidth = 40;
const Color relationColorTransparent = Colors.transparent;//デバッグ時に変えられるよう定義
//タイトル行のスクロール用
StateProvider<double> canvasScrollDeltaX = StateProvider((ref) => 0);
StateProvider<double> canvasScrollDeltaY = StateProvider((ref) => 0);
//2点間の距離を求める関数
double getDistance(double x, double y, double x2, double y2) {
double distance = sqrt((x2 - x) * (x2 - x) + (y2 - y) * (y2 - y));
return distance;
}
//2点間の角度を求める関数
double getRadian(double x, double y, double x2, double y2) {
double radian = atan2(y2 - y,x2 - x);
return radian;
}
//右向き矢印
class TrianglePainterR extends CustomPainter{
Color color;
TrianglePainterR(this.color);
@override
void paint(Canvas canvas, Size size) {
var paint = Paint()..color = color;
// 三角(塗りつぶし)
var path = Path();
path.moveTo(0, 0);
path.lineTo(size.width, size.height / 2);
path.lineTo(0, size.height);
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
//左向き矢印
class TrianglePainterL extends CustomPainter{
Color color;
TrianglePainterL(this.color);
@override
void paint(Canvas canvas, Size size) {
var paint = Paint()..color = color;
// 三角(塗りつぶし)
var path = Path();
path.moveTo(size.width, 0);
path.lineTo(0, size.height / 2);
path.lineTo(size.width, size.height);
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(CustomPainter oldDelegate) => false;
}
void removeRelation(List<IdeasRelation> ideasRelationList, IdeasRelation targetRelation) {
targetRelation.fromIdea.relatedIdeasTo.remove(targetRelation.toIdea);
targetRelation.toIdea.relatedIdeasFrom.remove(targetRelation.fromIdea);
ideasRelationList.remove(targetRelation);
}
Future<dynamic> relationSelectDialog(BuildContext context, CanvasZoneState state){
var isSelected = <bool>[false, false, false, false];
GlobalKey toggleButtonsKey = GlobalKey();
GlobalKey dialogKey = GlobalKey();
final TextEditingController textController = TextEditingController();
IdeasRelation? targetRelation = state.ref.read(selectedRelation);
if(targetRelation != null) {
if(targetRelation.fromShape == RelationShape.dot && targetRelation.toShape == RelationShape.dot) {
isSelected[0] = true;
} else if(targetRelation.fromShape == RelationShape.dot && targetRelation.toShape == RelationShape.arrow) {
// originとrelationの左右の位置によって、反映対象を変える
if (targetRelation.fromIdea.parentLane!.index <= targetRelation.toIdea.parentLane!.index) {
isSelected[1] = true;
} else {
isSelected[2] = true;
}
} else if(targetRelation.fromShape == RelationShape.arrow && targetRelation.toShape == RelationShape.dot) {
if (targetRelation.fromIdea.parentLane!.index <= targetRelation.toIdea.parentLane!.index) {
isSelected[2] = true;
} else {
isSelected[1] = true;
}
} else if(targetRelation.fromShape == RelationShape.arrow && targetRelation.toShape == RelationShape.arrow) {
isSelected[3] = true;
}
textController.text = targetRelation.note;
}
return showDialog(
context: context,
builder: (context) {
return StatefulBuilder(
builder: (context, setState) {
return SimpleDialog(
key: dialogKey,
contentPadding: const EdgeInsets.all(26),
children: [
Align( // 閉じるボタン
alignment: Alignment.centerRight,
child: Tooltip(
message: MaterialLocalizations
.of(context)
.closeButtonTooltip,
child: GestureDetector(
onTap: () => Navigator.pop(context),
child: const Icon(Icons.close, size: 30),
),
),
),
const Text('矢印の種類'),
Container(height: 10,),
ToggleButtons(
key: toggleButtonsKey,
direction: Axis.vertical,
borderColor: Colors.white,
selectedColor: Colors.lightBlueAccent,
selectedBorderColor: Colors.white,
isSelected: isSelected,
onPressed: (index) {
// The button that is tapped is set to true, and the others to false.
setState(() {
for (int i = 0; i < isSelected.length; i++) {
isSelected[i] = i == index;
}
});
},
children: [
SizedBox(
height: 40,
width: 180,
child: Stack(
children: drawRelationLineAt(state, 30,20,150,20, RelationShape.dot, RelationShape.dot, null),
),
),
SizedBox(
height: 40,
width: 180,
child: Stack(
children: drawRelationLineAt(state, 30,20,150,20, RelationShape.dot, RelationShape.arrow, null),
),
),
SizedBox(
height: 40,
width: 180,
child: Stack(
children: drawRelationLineAt(state, 30,20,150,20, RelationShape.arrow, RelationShape.dot, null),
),
),
SizedBox(
height: 40,
width: 180,
child: Stack(
children: drawRelationLineAt(state, 30,20,150,20, RelationShape.arrow, RelationShape.arrow, null),
),
),
],
),
Container(height: 10),
const Text('線の説明'),
TextField(
controller: textController,
decoration: InputDecoration(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
),
),
Container(height: 15),
const Divider(),
Container(height: 15),
Align(alignment: Alignment.center, child:
SizedBox(
width: 120,
height: 40,
child:
ElevatedButton(
onPressed: () { // 決定ボタン押下時
if (isSelected[0]) {
targetRelation!.fromShape = RelationShape.dot;
targetRelation.toShape = RelationShape.dot;
} else if (isSelected[1]) {
// originとrelationの左右の位置によって、反映対象を変える
if (targetRelation!.fromIdea.parentLane!.index <= targetRelation.toIdea.parentLane!.index) {
targetRelation.fromShape = RelationShape.dot;
targetRelation.toShape = RelationShape.arrow;
} else {
targetRelation.fromShape = RelationShape.arrow;
targetRelation.toShape = RelationShape.dot;
}
} else if (isSelected[2]) {
// originとrelationの左右の位置によって、反映対象を変える
if (targetRelation!.fromIdea.parentLane!.index <= targetRelation.toIdea.parentLane!.index) {
targetRelation.fromShape = RelationShape.arrow;
targetRelation.toShape = RelationShape.dot;
} else {
targetRelation.fromShape = RelationShape.dot;
targetRelation.toShape = RelationShape.arrow;
}
} else if (isSelected[3]) {
targetRelation!.fromShape = RelationShape.arrow;
targetRelation.toShape = RelationShape.arrow;
}
targetRelation!.note = textController.text;
Navigator.pop(context);
},
child: const Text('決定')
),
),
),
Container(height: 15),
Align(alignment: Alignment.bottomCenter, child:
Consumer(
builder: (context, ref, child) {
return TextButton(
style: ButtonStyle(
foregroundColor: MaterialStateProperty.all<Color>(Colors.redAccent),
),
onPressed: () async {
// 確認ダイアログを表示
var result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('確認'),
content: const Text('線を削除してよろしいですか?'),
actions: <Widget>[
TextButton(
child: const Text('Cancel'),
onPressed: () => Navigator.of(context).pop(false),
),
TextButton(
child: const Text('OK'),
onPressed: () => Navigator.of(context).pop(true),
),
],
);
},
);
if (result == true) {
//リレーション削除の実行
removeRelation( ref.watch(ideasRelationList.notifier).state , targetRelation! );
Navigator.pop(context);
}
},
child: const Text('線の削除')
);
}
),
),
],
);
},
);
},
);
}
Size _getTextSize(String text, TextStyle style) {
final TextPainter textPainter = TextPainter(
text: TextSpan(text: text, style: style), maxLines: 1, textDirection: TextDirection.ltr)
..layout(minWidth: 0, maxWidth: double.infinity);
return textPainter.size;
}
// 指定した座標を元にリレーションの線と文字を描画
List <Widget> drawRelationLineAt(CanvasZoneState state, double startX, double startY, double endX, double endY, RelationShape startShape, RelationShape endShape, IdeasRelation? ideasRelation) {
List <Widget> list = [];
final double dotSize = state.ref.watch(relationDotSize);
final double arrowSize = state.ref.watch(relationArrowSize);
double rotateAdjustX = 0;
double rotateAdjustY = 0;
double rotateAdjustXForTransparent = 0;
double rotateAdjustYForTransparent = 0;
double scrollDeltaX = state.ref.watch(canvasScrollDeltaX);
bool hoverFlg = false;
double lineWidthDiv2 = 1;
void focusEnter() {
state.ref.watch(relationHoverFlg.notifier).state = true;
state.ref.watch(selectedRelation.notifier).state = ideasRelation;
}
void focusExit() {
state.ref.watch(relationHoverFlg.notifier).state = false;
state.ref.watch(selectedRelation.notifier).state = null;
}
if(ideasRelation != null) {
if ( state.ref.watch(relationHoverFlg) && state.ref.read(selectedRelation) == ideasRelation ) {
hoverFlg = true;
}
}
// 指定座標にドットを描画
Widget makeDotAt(double dx, double dy) {
return Positioned(
top: dy - (dotSize / 2),
left: dx - (dotSize / 2) + scrollDeltaX,
width: dotSize,
height: dotSize,
child: MouseRegion(
onEnter: (_) => focusEnter(),
onExit: (_) => focusExit(),
child: Container(
decoration: BoxDecoration(
color: hoverFlg ? Colors.lightBlueAccent : Colors.blue,
shape: BoxShape.circle,
),
),
),
);
}
/* ドットと三角の描画 */
if(startShape == RelationShape.dot) {
list.add( //ドットの描画 Origin
makeDotAt( startX, startY )
);
} else {
list.add( //三角の描画
Positioned(
top: startY - (arrowSize / 2),
left: startX - (arrowSize / 2) + scrollDeltaX,
child: Transform.rotate(
angle: (startX == endX) /*レーンが同じ場合*/ ? 0 : getRadian(startX + scrollDeltaX, startY, endX + scrollDeltaX, endY),
alignment: Alignment.center,
child: MouseRegion(
onEnter: (_) => focusEnter(),
onExit: (_) => focusExit(),
child: CustomPaint(
painter: TrianglePainterL(hoverFlg ? Colors.lightBlueAccent : Colors.blue),
child: SizedBox(
width: arrowSize,
height: arrowSize,
),
),
),
),
),
);
}
if(endShape == RelationShape.dot) {
list.add( //ドットの描画 Relation
makeDotAt( endX, endY )
);
} else {
list.add( //三角の描画
Positioned(
top: endY - (arrowSize / 2),
left: endX - (arrowSize / 2) + scrollDeltaX,
child: Transform.rotate(
angle: (startX == endX) /*レーンが同じ場合*/ ? (180 * pi / 180) : getRadian(startX + scrollDeltaX, startY, endX + scrollDeltaX, endY),
alignment: Alignment.center,
child: MouseRegion(
onEnter: (_) => focusEnter(),
onExit: (_) => focusExit(),
child: CustomPaint(
painter: TrianglePainterR(hoverFlg ? Colors.lightBlueAccent : Colors.blue),
child: SizedBox(
width: arrowSize,
height: arrowSize,
),
),
),
),
),
);
}
double lineTop=0,lineLeft=0,lineHeight=0,lineWidth=0;
/* 線の描画 */
if (startX != endX) {
/*レーンが違う場合*/
if ( (endY - startY) > 30) { //右側の点が下側。回転すると、線の太さだけずれて見えてしまうので、それを調整する
rotateAdjustX = 1;
rotateAdjustY = 1;
rotateAdjustXForTransparent = 2;
rotateAdjustYForTransparent = 0;
} else if ( (endY - startY) < -30) { //右側の点が上側。回転すると、線の太さだけずれて見えてしまうので、それを調整する
rotateAdjustX = -1;
rotateAdjustY = 0;
rotateAdjustXForTransparent = -2;
rotateAdjustYForTransparent = 0;
}
lineTop = startY - lineWidthDiv2 + rotateAdjustY;
lineLeft = startX + lineWidthDiv2 + scrollDeltaX + rotateAdjustX;
lineWidth = getDistance(startX + scrollDeltaX, startY, endX + scrollDeltaX, endY) - 2;
lineHeight= 2;
list.add( //線の描画
Stack(
children:[
Positioned( //実際の線の描画
top: lineTop,
left: lineLeft,
child: Transform.rotate(
angle: getRadian(startX + scrollDeltaX, startY, endX + scrollDeltaX, endY),
alignment: Alignment.topLeft,
origin: const Offset( -0.5, -0.5 ),
child: Container(
width: lineWidth,
height: lineHeight,
decoration: BoxDecoration(
color: hoverFlg ? Colors.lightBlueAccent : Colors.blue,
),
),
),
),
Positioned( //当たり判定用の透明な線
top: startY - lineWidthDiv2 + rotateAdjustY + rotateAdjustYForTransparent - 3/*線の太さ分を調整*/,
left: startX + lineWidthDiv2 + scrollDeltaX + rotateAdjustXForTransparent + rotateAdjustX - 1,
child: Transform.rotate(
angle: getRadian(startX + scrollDeltaX, startY, endX + scrollDeltaX, endY),
alignment: Alignment.topLeft,
// origin: Offset( state.ref.watch(relationDotSize)/2 , state.ref.watch(relationDotSize)/2 ),
child: MouseRegion(
onEnter: (_) => focusEnter(),
onExit: (_) => focusExit(),
child: Container(
width: getDistance(startX + scrollDeltaX, startY, endX + scrollDeltaX, endY),
height: 8,
decoration: const BoxDecoration( color: relationColorTransparent ),
),
),
),
),
]
),
);
} else {
/*レーンが同じ場合*/
double horizontalMargin = ideaHorizontalMargin - 2;
lineTop = startY - lineWidthDiv2;
lineLeft = startX + scrollDeltaX + horizontalMargin;
lineWidth = 2;
lineHeight= endY- startY + (lineWidthDiv2*2) /*線の太さ分を調整*/;
list.add( //線の描画
Stack(
children:[
Positioned( //実際の線の描画(縦)
top: lineTop,
left: lineLeft,
child: Container(
width: 2,
height: lineHeight,
decoration: BoxDecoration(
color: hoverFlg ? Colors.lightBlueAccent : Colors.blue,
),
),
),
Positioned( //実際の線の描画(横1)
top: startY - lineWidthDiv2,
left: startX + lineWidthDiv2 + scrollDeltaX,
child: Container(
width: horizontalMargin,
height: 2,
decoration: BoxDecoration(
color: hoverFlg ? Colors.lightBlueAccent : Colors.blue,
),
),
),
Positioned( //実際の線の描画(横2)
top: endY - lineWidthDiv2,
left: endX + lineWidthDiv2 + scrollDeltaX,
child: Container(
width: horizontalMargin,
height: 2,
decoration: BoxDecoration(
color: hoverFlg ? Colors.lightBlueAccent : Colors.blue,
),
),
),
Positioned( //当たり判定用の透明な線(縦)
top: startY - lineWidthDiv2 - 2 /*線の太さ分を調整*/,
left: startX + scrollDeltaX + horizontalMargin - 2,
child: MouseRegion(
onEnter: (_) => focusEnter(),
onExit: (_) => focusExit(),
child: Container(
width: 2 + 4,
height: endY - startY + (lineWidthDiv2*2) /*線の太さ分を調整*/ + 4,
decoration: const BoxDecoration( color: relationColorTransparent ),
),
),
),
Positioned( //当たり判定用の透明な線(横1)
top: startY - lineWidthDiv2 - 2 /*線の太さ分を調整*/,
left: startX + lineWidthDiv2 + scrollDeltaX,
child: MouseRegion(
onEnter: (_) => focusEnter(),
onExit: (_) => focusExit(),
child: Container(
width: horizontalMargin,
height: 2 + 4,
decoration: const BoxDecoration( color: relationColorTransparent ),
),
),
),
Positioned( //当たり判定用の透明な線(横2)
top: endY - lineWidthDiv2 - 2 /*線の太さ分を調整*/,
left: endX + lineWidthDiv2 + scrollDeltaX,
child: MouseRegion(
onEnter: (_) => focusEnter(),
onExit: (_) => focusExit(),
child: Container(
width: horizontalMargin,
height: 2 + 4,
decoration: const BoxDecoration( color: relationColorTransparent ),
),
),
),
]
),
);
}
String note = '';
if(ideasRelation != null) {
note = ideasRelation.note;
}
/* 文字の描画 */
if ( note != '' ) {
/* 線の描画 */
if (startX != endX) {
/*レーンが違う場合*/
double radian = getRadian(startX + scrollDeltaX, startY, endX + scrollDeltaX, endY);
// radianのイメージ
// -90° = (-90* pi / 180) =-1.5707963267948966
// -45° = (-45* pi / 180) =-0.7853981633974483
// 0° = ( 0 * pi / 180) = 0
// 45° = (45 * pi / 180) = 0.7853981633974483
// 90° = (90 * pi / 180) = 1.5707963267948966
// 180° = pi = 3.14
// 手入力での調整結果
// -90° left: - 18, top: - 0,
// -75° left: - 15, top: - 8,
// -45° left: - 12, top: - 12,
// -30° left: - 5, top: - 15,
// 0° left: - 0, top: - 18,
//
// Offset(13*radian, -19 + (5*radian.abs())),
// -90° left: -20.4, top: - 19+7.8= -11.2
// -45° left: -10.2, top: - 19+3.9= -15.1
// 0° left: 0, top: - 19,
// 45° left: 10.2, top: - 19+3.9= -15.1
// 90° left: 20.4, top: - 19+7.8= -11.2
//
//print("$radian (-45 * pi / 180):${(-45 * pi / 180)} (90 * pi / 180):${(90 * pi / 180)} (180 * pi / 180):${(180 * pi / 180)}");
list.add( //文字の描画
Stack(
children: [
Positioned( //文字の描画
top: lineTop,
left: lineLeft,
width: lineWidth,
child: Align(
alignment: Alignment.center,
child: Transform.translate(
offset: Offset(13 * radian, -19 + (4 * radian.abs())),
child: Transform.rotate(
angle: radian,
alignment: Alignment.topLeft,
origin: const Offset(-0.5, -0.5),
child: Container(
color: Colors.transparent,
alignment: Alignment.center,
child: Text(note, style: const TextStyle(color: Colors.blue),),
),
),
),
),
),
]
),
);
} else {
/*レーンが同じ場合*/
double radian = getRadian(lineLeft, lineTop, lineLeft, lineTop+lineHeight);
Size textSize = _getTextSize(note, const TextStyle(color: Colors.blue));
list.add( //文字の描画
Stack(
children: [
Positioned( //文字の描画
top: lineTop,
left: lineLeft,
child: Align(
alignment: Alignment.center,
child: Transform.translate(
offset: Offset(textSize.height + 4, (lineHeight/2) - (textSize.width/2) ),
child: Transform.rotate(
angle: radian,
alignment: Alignment.topLeft,
origin: const Offset(-0.5, -0.5),
child: Container(
color: Colors.transparent,
alignment: Alignment.center,
child: Text(note, style: const TextStyle(color: Colors.blue),),
),
),
),
),
),
]
),
);
}
}
//デバッグ用
if(relationDebugPaint) {
list.add(
Positioned( //デバッグ用
top: startY,
left: startX + scrollDeltaX,
width: 1,
height: 1,
child: Container(
decoration: const BoxDecoration(
color: Colors.red,
shape: BoxShape.circle,
),
),
),
);
list.add(
Positioned( //デバッグ用
top: startY - lineWidthDiv2 + rotateAdjustY,
left: startX + lineWidthDiv2 + scrollDeltaX,
width: 1,
height: 1,
child: Container(
decoration: const BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
),
),
),
);
}
return list;
}
// relationを元にリレーション描画ウィジェットを作成
List<Widget> buildRelationLineWidget(CanvasZoneState state, IdeasRelation relationSet) {
Idea startIdea, endIdea;
RelationShape startShape, endShape;
double startX, startY, endY, endX;
final double laneWidth = state.ref.watch(eachLaneWidth);
final double ideaHeight = state.ref.watch(eachIdeaHeight);
if (relationSet.fromIdea.parentLane!.index != relationSet.toIdea.parentLane!.index ) {
// leftを必ずstartIdeaとしてしまう。左右逆のパターンもロジックを記述すると、回転の計算などが面倒になったため
if (relationSet.fromIdea.parentLane!.index <= relationSet.toIdea.parentLane!.index) {
startIdea = relationSet.fromIdea;
startShape = relationSet.fromShape;
endIdea = relationSet.toIdea;
endShape = relationSet.toShape;
} else {
startIdea = relationSet.toIdea;
startShape = relationSet.toShape;
endIdea = relationSet.fromIdea;
endShape = relationSet.fromShape;
}
// 描画位置から求めると、パン・スクロールしたときにどんどんずれていく
// RenderBox originIdeaTextFieldBox = origin.textFieldKey.currentContext?.findRenderObject() as RenderBox;
startY = (ideaHeight * startIdea.index) + (ideaHeight/2) - 0.5/*調整用。これが無いとずれて見える*/ ;
startX = (startIdea.parentLane!.index + 1) * laneWidth - ideaHorizontalMargin - 0.5/*調整用。これが無いとずれて見える*/;
endY = (ideaHeight * endIdea.index) + (ideaHeight/2) - 0.5/*調整用。これが無いとずれて見える*/;
endX = (laneWidth * endIdea.parentLane!.index) + ideaHorizontalMargin + ideaLeftSpace + (indentWidth * endIdea.indentLevel) + 0.5/*調整用。これが無いとずれて見える*/;
/* 見やすくするためのずらし調整(インデント) */
if (startIdea.parentLane!.index < endIdea.parentLane!.index &&
endIdea.indentLevel >= 1) { //Relatedが右側にあり、インデントされている場合
endY += 3; //Related側のインデントの線と重なるのを避けるために少しずらす
}
/* 見やすくするためのずらし調整(他の矢印やドットとの重なり:右側) */
if ( (endY - startY).abs() > 10) { // 線に角度が付いている場合
bool avoidFlg = false;
//対象のアイディアに、他にリレーションが付いているか探す
if(endIdea.relatedIdeasFrom.length >= 2) {
avoidFlg = true;
}
if (avoidFlg) {
if (startY < endY - 10) { // 矢印が斜めの場合
endY -= 6; //Related側の矢印と重なるのを避けるために少しずらす
} else if (startY > endY) {
endY += 6;
}
}
/* 見やすくするためのずらし調整(他の矢印やドットとの重なり:右側) */
avoidFlg = false;
//対象のアイディアに、他にリレーションが付いているか探す
if(startIdea.relatedIdeasTo.length >= 2) {
avoidFlg = true;
}
if (avoidFlg) {
if (startY < endY - 10) { // 矢印が斜めの場合
startY += 6; //Related側の矢印と重なるのを避けるために少しずらす
} else if (startY > endY) {
startY -= 6;
}
}
}
} else{
/* 同じレーン同士の場合 */
// 計算を簡単にするため、上を必ずstartIdeaとしてしまう
if (relationSet.fromIdea.index <= relationSet.toIdea.index) {
startIdea = relationSet.fromIdea;
startShape = relationSet.fromShape;
endIdea = relationSet.toIdea;
endShape = relationSet.toShape;
} else {
startIdea = relationSet.toIdea;
startShape = relationSet.toShape;
endIdea = relationSet.fromIdea;
endShape = relationSet.fromShape;
}
startY = (ideaHeight * startIdea.index) + (ideaHeight/2) - 0.5/*調整用。これが無いとずれて見える*/ ;
startX = (startIdea.parentLane!.index + 1) * laneWidth - ideaHorizontalMargin - 0.5/*調整用。これが無いとずれて見える*/;
endY = (ideaHeight * endIdea.index) + (ideaHeight/2) - 0.5/*調整用。これが無いとずれて見える*/;
endX = startX;
/* 見やすくするためのずらし調整(他の矢印やドットとの重なり:右側) */
bool avoidFlg = false;
//対象のアイディアに、他にリレーションが付いているか探す
if(startIdea.relatedIdeasTo.length >= 2) {
avoidFlg = true;
}
if (avoidFlg) {
if (startY < endY - 10) { // 矢印が斜めの場合
startY += 6; //Related側の矢印と重なるのを避けるために少しずらす
} else if (startY > endY) {
startY -= 6;
}
}
}
if(relationDebugPaint) {
List<Widget> result = drawRelationLineAt(state, startX,startY,endX,endY, startShape,endShape, relationSet) ;
//座標に関するデバッグ用 originの表示
result.add(
Stack(children: [
Positioned( //デバッグ用 originの表示
top: startIdea == relationSet.fromIdea ? startY : endY,
left: startIdea == relationSet.fromIdea ? startX + state.ref.watch(canvasScrollDeltaX) : endX + state.ref.watch(canvasScrollDeltaX),
child: Container(
width: 3,
height: 3,
decoration: const BoxDecoration(color: Colors.deepPurpleAccent,),
),
),
])
);
//デバッグ用 dotの中心線
result.add(
Stack(children: [
Positioned( //デバッグ用
top: endY,
left: endX + state.ref.watch(canvasScrollDeltaX),
child: Container(
width: 500,
height: 0.5,
decoration: const BoxDecoration(color: Colors.red,),
),
),
])
);
return result;
} else {
return drawRelationLineAt(state, startX,startY,endX,endY, startShape,endShape, relationSet) ;
}
}
List <Widget> buildIdeasAndRelations(CanvasZoneState state) {
List <Widget> list = [];
final double laneWidth = state.ref.watch(eachLaneWidth);
/* Ideas */
list.add(
Positioned( // Ideasの描画
top: 0,
left: 0 + state.ref.watch(canvasScrollDeltaX),
width: laneWidth * state.lanes.length,
height: state.ref.watch(lanesBodyCanvasHeight),
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: state.lanes.length,
itemBuilder: (context, index) {
return SizedBox( // Laneごとの箱
width: laneWidth,
child: state.lanes[index],
);
},
),
),
);
if (state.widget.key.currentState == null) return list;
if (state.widget.key.currentState!.lanes.isEmpty) return list;
if (state.widget.key.currentState!.lanes[0].ideas.isEmpty) return list;
/* Relation */
IdeasRelation? lazyBuildTarget;
for (IdeasRelation relationSet in state.ref.watch(ideasRelationList)) {
if ( state.ref.watch(relationHoverFlg) && state.ref.read(selectedRelation) == relationSet ) {
//アクティブな線は、最後に描画することで手前に持ってくる(そうしないと他の線に隠れてしまう)
lazyBuildTarget = relationSet;
} else {
list.addAll( buildRelationLineWidget(state, relationSet) );
}
}
if(lazyBuildTarget != null) {
list.addAll( buildRelationLineWidget(state, lazyBuildTarget) );
}
/*
//デバッグ用
list.add(
Stack(children: [
Positioned( //デバッグ用
top: 0,
left: state.ref.watch(eachLaneWidth) - ideaHorizontalMargin - 50 + state.ref.watch(canvasScrollDeltaX),
child: Container(
width: 500,
height: 1,
decoration: BoxDecoration(color: Colors.red,),
),
),
Positioned( //デバッグ用
top: (ideaHeight / 2),
left: state.ref.watch(eachLaneWidth) - ideaHorizontalMargin - 50 + state.ref.watch(canvasScrollDeltaX),
child: Container(
width: 1000,
height: 0.5,
decoration: BoxDecoration(color: Colors.green,),
),
),
],
),
);
*/
return list;
}
void indentLinePaint(Canvas canvas,Size size,BuildContext context, int index, int indentLevel, double textFieldHeight, double indentWidth) {
// インデントの線を描画する
final paint = Paint()
..color = Colors.grey
..strokeWidth = 1;
var leftX = indentWidth / 2;
// 一行上のIdeaのindentLevelを取得する
int upperIndentLevel = 0;
int upperIndex = index - 1;
//描画:Canvasの起点は左側センター
if (indentLevel > 0 && index > 0 /*一番上の時はツリーを描画しない*/) {
//一番上の親が居ない場合の特別対応:自分の上のどこかに、indentLevelが小さい親がいるかチェック
bool cancelFlg = false; //描画しない特別な場合用
var i = index - 1;
while (i >= 0) {
if ((LaneBodyInheritedWidget.of(context)
.ideas[i].indentLevel) <
indentLevel) {
break;
}
i--;
}
if (i == -1) cancelFlg = true;
if (!cancelFlg) {
//縦の線:左側の基準
leftX = leftX + (indentWidth * (indentLevel - 1));
//縦の線:自分の領域内で上に引く
double startY = 0 - (textFieldHeight / 2) - (ideaVerticalMargin * 2);
//縦の線:自分の領域外でどのくらい上まで引くかを計算
upperIndentLevel = LaneBodyInheritedWidget.of(context)
.ideas[upperIndex].indentLevel;
if (indentLevel <= upperIndentLevel) {
while (indentLevel < upperIndentLevel && upperIndex >= 0) {
startY -= (textFieldHeight + ideaVerticalMargin * 2);
upperIndex--;
upperIndentLevel = LaneBodyInheritedWidget.of(context)
.ideas[upperIndex].indentLevel;
}
if (indentLevel <= upperIndentLevel) {
startY -= textFieldHeight / 2;
}
}
//縦の線
canvas.drawLine(Offset(leftX, 0), Offset(leftX, startY), paint);
//横の線
canvas.drawLine(Offset(leftX, size.height),Offset(indentWidth * indentLevel, size.height), paint);
}
}
}
void findAndRepaintAffectedIdeas(BuildContext context, int myIndex, int myIndentLevel, int startIndex, int endIndex){
// Find the start index
for (int i = myIndex - 1; i >= 0; i--) {
if (LaneBodyInheritedWidget.of(context)
.ideas[i].indentLevel == myIndentLevel - 1) {
startIndex = i;
break;
}
}
// Find the end index
for (int i = myIndex + 1;
i < LaneBodyInheritedWidget.of(context).ideas.length;
i++) {
if ((LaneBodyInheritedWidget.of(context)
.ideas[i].indentLevel) >= myIndentLevel) {
endIndex = i;
break;
}
}
LaneBodyInheritedWidget.of(context).redrawAffectedIdeas(startIndex, endIndex);
CanvasZoneInheritedWidget.of(context).setState(() {}); //Relationの線を描画
}
Future<String> get _defaultFileName async {
final directory = await getApplicationDocumentsDirectory();
return '${directory.path}/default.json';
}
void showAlert( String msg, BuildContext context) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
content: Text(msg),
actions: [
TextButton(
onPressed: () {
Navigator.pop(context, 'OK');
},
child: const Text('OK'))
],);
} );
}
Future<void> saveData({String? fileName, required CanvasZoneState? state}) async {
List<Map<String, dynamic>> lanesJson = [];
fileName ??= await _defaultFileName;
if(state == null) return;
for (var lane in state.lanes ) {
List<Map<String, dynamic>> ideasJson = [];
for (var idea in lane.ideas) {
ideasJson.add({
'index': idea.index,
'indentLevel': idea.indentLevel,
'content': idea.key.currentState?.textController.text ?? '',
});
}
lanesJson.add({
'key': lane.key.toString(),
'index': lane.index,
'title': state.laneHeaders[lane.index].key.currentState?.titleController.text ?? '',
'ideas': ideasJson,
});
}
List<Map<String, dynamic>> ideasRelationJson = [];
state.ref.read(ideasRelationList).forEach((relationSet) {
ideasRelationJson.add({
'from_lane_index': relationSet.fromIdea.parentLane?.index,
'from_idea_index': relationSet.fromIdea.index,
'from_shape': relationSet.fromShape == RelationShape.dot ? 'dot' : 'arrow',
'to_lane_index': relationSet.toIdea.parentLane?.index,
'to_idea_index': relationSet.toIdea.index,
'to_shape': relationSet.toShape == RelationShape.dot ? 'dot' : 'arrow',
'note': relationSet.note,
});
});
String jsonString = jsonEncode({'lanes': lanesJson, 'ideasRelation': ideasRelationJson});
await File(fileName).writeAsString(jsonString);
}
Future<void> loadData({String? fileName, required CanvasZoneState? state}) async {
fileName ??= await _defaultFileName;
final file = File(fileName);
if (await file.exists() == false) {
showAlert('No such files:{$fileName}', state!.context);
return;
}
String jsonString = await file.readAsString();
Map<String, dynamic> jsonMap = jsonDecode(jsonString);
if(state == null) return;
List<LaneBody> loadedLanes = [];
List<LaneHeader> loadedLaneHeaders = [];
for (var laneJson in jsonMap['lanes']) {
List<Idea> loadedIdeas = [];
var newLane = LaneBody(
index: laneJson['index'] ?? 0,
title: laneJson['title'] ?? '',
ideas: loadedIdeas,
);
var newLaneHeader = LaneHeader(index: laneJson['index'] ?? 0, relatedLane: newLane);
newLaneHeader.title = laneJson['title'] ?? '';
loadedLaneHeaders.add(newLaneHeader);
for (var ideaJson in laneJson['ideas']) {
loadedIdeas.add(Idea(
index: ideaJson['index'],
indentLevel: ideaJson['indentLevel'],
parentLane: newLane,
));
}
loadedLanes.add(newLane);
}
for (int i = 0; i < loadedLanes.length; i++) {
for (int j = 0; j < loadedLanes[i].ideas.length; j++) {
loadedLanes[i].ideas[j].content = jsonMap['lanes'][i]['ideas'][j]['content'];
}
}
state.lanes = loadedLanes;
state.laneHeaders = loadedLaneHeaders;
state.ref.watch(ideasRelationList.notifier).state.clear();
if (jsonMap['ideasRelation'] != null) {
for (var ideaRelationJson in jsonMap['ideasRelation']) {
var fromLaneIndex = ideaRelationJson['from_lane_index'];
var fromIdeaIndex = ideaRelationJson['from_idea_index'];
var toLaneIndex = ideaRelationJson['to_lane_index'];
var toIdeaIndex = ideaRelationJson['to_idea_index'];
IdeasRelation relationSet = IdeasRelation(
fromIdea: loadedLanes[fromLaneIndex].ideas[fromIdeaIndex], toIdea: loadedLanes[toLaneIndex].ideas[toIdeaIndex]);
if(ideaRelationJson['from_shape'] == 'arrow') {
relationSet.fromShape = RelationShape.arrow;
}
if(ideaRelationJson['to_shape'] == 'arrow') {
relationSet.toShape = RelationShape.arrow;
}
relationSet.note = ideaRelationJson['note'];
state.ref.watch(ideasRelationList.notifier).state.add(relationSet);
loadedLanes[fromLaneIndex].ideas[fromIdeaIndex].relatedIdeasTo.add(loadedLanes[toLaneIndex].ideas[toIdeaIndex]);
loadedLanes[toLaneIndex].ideas[toIdeaIndex].relatedIdeasFrom.add(loadedLanes[fromLaneIndex].ideas[fromIdeaIndex]);
}
}
}
2023/04/29
- 引き続きスクロールの調整。拡大縮小も含めて全て手動計算になってつらい。
- しかし自分で制御出来るので、意味不明過ぎて詰むという事は無くなってきた。
※本記事は当時の記録をもとに作成し、必要に応じて加筆・補足しています
テキストベースの思考整理ツール「アイディア・レーン」最新版はこちら。
コメント